Skip to content

Commit 5447524

Browse files
committed
feat: implement dynamic weight-based shipping rates with stripe & shippo integration
1 parent e28abb1 commit 5447524

File tree

9 files changed

+126
-40
lines changed

9 files changed

+126
-40
lines changed

app/admin/analytics/page.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export default async function AnalyticsPage() {
5353
{ data: monthly },
5454
{ data: topItems },
5555
] = await Promise.all([
56-
supabase.from('orders').select('amount_total, created_at').eq('status', 'paid'),
56+
supabase.from('orders').select('amount_total, created_at, metadata').eq('status', 'paid'),
5757
supabase.from('orders').select('*', { count: 'exact', head: true }),
5858
supabase.from('profiles').select('*', { count: 'exact', head: true }),
5959
supabase.from('products').select('*', { count: 'exact', head: true }).eq('status', 'active'),
@@ -65,6 +65,12 @@ export default async function AnalyticsPage() {
6565
])
6666

6767
const grossRevenue = paidOrders?.reduce((s, o) => s + (o.amount_total || 0), 0) ?? 0
68+
const shippingRevenue = paidOrders?.reduce((sum, o) => {
69+
const shippingCost = (o.metadata as any)?.shipping_cost_cents
70+
? Number((o.metadata as any).shipping_cost_cents) / 100
71+
: 0;
72+
return sum + shippingCost;
73+
}, 0) || 0;
6874
const aov = (totalOrders ?? 0) > 0 ? grossRevenue / (totalOrders ?? 1) : 0
6975

7076
const w7 = buildDailyTotals((weekly ?? []) as { amount_total: number; created_at: string }[], 7)
@@ -99,9 +105,9 @@ export default async function AnalyticsPage() {
99105
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
100106
{[
101107
{ label: 'Gross Revenue', value: `$${grossRevenue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`, sub: 'From paid orders', gold: true },
108+
{ label: 'Shipping Revenue', value: `$${shippingRevenue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`, sub: 'From logistics' },
102109
{ label: 'Avg. Order Value', value: `$${aov.toFixed(2)}`, sub: `${(totalOrders ?? 0).toLocaleString()} total orders` },
103110
{ label: 'Total Customers', value: (totalCustomers ?? 0).toLocaleString(), sub: 'Registered profiles' },
104-
{ label: 'Active Products', value: (totalProducts ?? 0).toLocaleString(), sub: 'In catalog' },
105111
].map(k => (
106112
<div key={k.label} className="bg-[#0B0B0D] rounded-luxury border border-white/10 p-5 shadow-sm hover:border-gold/30 transition-all group">
107113
<p className="text-[9px] uppercase tracking-[0.3em] text-luxury-subtext mb-2 font-medium">{k.label}</p>

app/admin/settings/page.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,23 @@ 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) - 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>
149+
<label className="text-[9px] uppercase tracking-luxury text-luxury-subtext font-medium">
150+
Weight Brackets (JSON)
151+
</label>
152+
<p className="text-[10px] text-emerald-400/60 leading-relaxed mb-2">
153+
✓ Format: [{`{ "max_lb": 0.5, "rate": 4.99 }`}, {`{ "max_lb": 1, "rate": 6.99 }`}, ...]<br/>
154+
✓ Orders match the FIRST bracket where weight ≤ max_lb<br/>
155+
✓ Always end with a catch-all: {`{ "max_lb": 999, "rate": 15.99 }`}<br/>
156+
✓ Example: 0.8 lb cart matches the 1 lb bracket
157+
</p>
151158
<textarea name="weight_brackets" defaultValue={JSON.stringify(shipping.weight_brackets || [
152159
{ max_lb: 0.5, rate: 4.99 },
153160
{ max_lb: 1, rate: 6.99 },
154161
{ max_lb: 2, rate: 8.99 },
155162
{ max_lb: 5, rate: 12.99 },
156163
{ max_lb: 999, rate: 15.99 }
157164
], null, 2)}
158-
rows={5} className="w-full bg-[#0B0B0D] border border-white/10 rounded-md px-4 py-3 text-xs font-mono text-emerald-400 focus:border-gold/50 outline-none transition-all" />
165+
rows={6} className="w-full bg-[#0B0B0D] border border-white/10 rounded-md px-4 py-3 text-xs font-mono text-emerald-400 focus:border-gold/50 outline-none transition-all" />
159166
</div>
160167
<div className="space-y-1.5">
161168
<label className="text-[9px] uppercase tracking-luxury text-luxury-subtext font-medium">Free Shipping Threshold</label>

app/api/checkout/route.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ const INTL_COUNTRIES = [
1414

1515
const ALL_COUNTRIES = ["US", ...INTL_COUNTRIES] as Stripe.Checkout.SessionCreateParams.ShippingAddressCollection.AllowedCountry[];
1616

17+
// ─── Caching ──────────────────────────────────────────────────────────
18+
let cachedShippingConfig: any = null;
19+
let cacheTimestamp = 0;
20+
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
21+
1722
export async function POST(req: Request) {
1823
try {
1924
const { items } = await req.json();
@@ -79,14 +84,20 @@ export async function POST(req: Request) {
7984
0
8085
);
8186

82-
// ─── Load shipping config ────────────────────────────────────────────
83-
const { data: shippingConfig } = await supabase
84-
.from("site_settings")
85-
.select("setting_value")
86-
.eq("setting_key", "shipping_settings")
87-
.maybeSingle();
87+
// ─── Load shipping config (cached) ──────────────────────────────────
88+
const now = Date.now();
89+
if (!cachedShippingConfig || (now - cacheTimestamp) > CACHE_TTL) {
90+
const { data: shippingConfig } = await supabase
91+
.from("site_settings")
92+
.select("setting_value")
93+
.eq("setting_key", "shipping_settings")
94+
.maybeSingle();
95+
96+
cachedShippingConfig = shippingConfig?.setting_value || {};
97+
cacheTimestamp = now;
98+
}
8899

89-
const cfg = shippingConfig?.setting_value || {};
100+
const cfg = cachedShippingConfig;
90101
const isFree = subtotal >= parseFloat(cfg.free_shipping_threshold ?? "100");
91102

92103
// Calculate all 4 options based on actual weight
@@ -104,7 +115,7 @@ export async function POST(req: Request) {
104115
amount: Math.round(stdRate.cost * 100),
105116
currency: "usd",
106117
},
107-
display_name: isFree ? "Free Standard Shipping 🎁" : `${stdRate.name} (US)`,
118+
display_name: stdRate.name,
108119
delivery_estimate: {
109120
minimum: { unit: "business_day", value: stdRate.minDays },
110121
maximum: { unit: "business_day", value: stdRate.maxDays },
@@ -119,7 +130,7 @@ export async function POST(req: Request) {
119130
amount: Math.round(expRate.cost * 100),
120131
currency: "usd",
121132
},
122-
display_name: `${expRate.name} (US)`,
133+
display_name: expRate.name,
123134
delivery_estimate: {
124135
minimum: { unit: "business_day", value: expRate.minDays },
125136
maximum: { unit: "business_day", value: expRate.maxDays },
@@ -134,7 +145,7 @@ export async function POST(req: Request) {
134145
amount: Math.round(intlStdRate.cost * 100),
135146
currency: "usd",
136147
},
137-
display_name: `${intlStdRate.name} 🌍`,
148+
display_name: intlStdRate.name,
138149
delivery_estimate: {
139150
minimum: { unit: "business_day", value: intlStdRate.minDays },
140151
maximum: { unit: "business_day", value: intlStdRate.maxDays },
@@ -149,7 +160,7 @@ export async function POST(req: Request) {
149160
amount: Math.round(intlExpRate.cost * 100),
150161
currency: "usd",
151162
},
152-
display_name: `${intlExpRate.name} 🚀`,
163+
display_name: intlExpRate.name,
153164
delivery_estimate: {
154165
minimum: { unit: "business_day", value: intlExpRate.minDays },
155166
maximum: { unit: "business_day", value: intlExpRate.maxDays },

app/checkout/page.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useState } from "react";
3+
import { useEffect, useState, useMemo } from "react";
44
import { useCart } from "@/context/CartContext";
55
import Link from "next/link";
66
import { ArrowLeft, Loader2, Package, ShieldCheck } from "lucide-react";
@@ -30,6 +30,13 @@ export default function CheckoutPage() {
3030
const isFreeShipping = cartTotal >= freeThreshold;
3131
const remaining = freeThreshold - cartTotal;
3232

33+
const totalWeightLb = useMemo(() => {
34+
return cart.reduce((total, item) => {
35+
const weightOz = item.variantWeight || item.productWeight || 2;
36+
return total + (weightOz * item.quantity);
37+
}, 0) / 16;
38+
}, [cart]);
39+
3340
const handleProceed = async () => {
3441
if (!cart.length) return;
3542
setLoadingCheckout(true);
@@ -111,6 +118,13 @@ export default function CheckoutPage() {
111118
<span>Subtotal</span>
112119
<span>${cartTotal.toFixed(2)}</span>
113120
</div>
121+
<div className="flex justify-between text-xs uppercase tracking-widest text-luxury-subtext transition-all duration-500 hover:text-white">
122+
<span className="flex items-center gap-2">
123+
<Package className="w-3 h-3 text-gold/60" />
124+
Estimated weight
125+
</span>
126+
<span>{totalWeightLb.toFixed(2)} lbs</span>
127+
</div>
114128
<div className="flex justify-between text-xs uppercase tracking-widest text-luxury-subtext">
115129
<span>Shipping</span>
116130
<span className={isFreeShipping ? "text-gold font-medium" : ""}>

app/product/[slug]/ProductClient.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface Product {
2525
on_sale?: boolean
2626
description: string
2727
images: string[]
28+
weight_oz?: number | null
2829
product_variants?: Variant[]
2930
}
3031

@@ -59,6 +60,7 @@ export function ProductClient({ product }: ProductClientProps) {
5960
on_sale: product.on_sale,
6061
description: product.description || "The quintessence of modern luxury.",
6162
images: (product.images && product.images.length > 0) ? product.images : ["/logo.jpg"],
63+
weight_oz: product.weight_oz,
6264
product_variants: product.product_variants,
6365
}}
6466
onVariantImageChange={setVariantImage}

context/CartContext.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export interface CartItem {
1111
image: string;
1212
quantity: number;
1313
variantName?: string;
14+
variantWeight?: number | null;
15+
productWeight?: number | null;
1416
}
1517

1618
interface CartContextType {

features/products/components/ProductDetails.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface Variant {
1313
stock: number
1414
color_code?: string | null
1515
image_url?: string | null
16+
weight?: number | null
1617
}
1718

1819
interface ProductDetailsProps {
@@ -24,6 +25,7 @@ interface ProductDetailsProps {
2425
on_sale?: boolean
2526
description: string
2627
images: string[]
28+
weight_oz?: number | null
2729
product_variants?: Variant[]
2830
}
2931
/** Called when a variant's image should update the gallery */
@@ -103,6 +105,8 @@ export function ProductDetails({ product, onVariantImageChange }: ProductDetails
103105
quantity: Math.min(quantity, currentStock),
104106
image: selectedVariant?.image_url || product.images[0] || "",
105107
variantName: selectedVariant?.name,
108+
variantWeight: selectedVariant?.weight ? Number(selectedVariant.weight) : null,
109+
productWeight: product.weight_oz ? Number(product.weight_oz) : null,
106110
})
107111
setIsCartOpen(true)
108112
}

lib/utils/shippo.ts

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,21 +42,44 @@ export function getParcelForWeight(totalWeightLb: number) {
4242
* Expects items that carry either variant_weight_oz or product_weight_oz (both in oz).
4343
* Falls back to 2 oz per item if neither is set.
4444
*/
45-
export function calculateTotalWeightLb(items: Array<{
46-
quantity: number
47-
variant_weight_oz?: number | null
48-
product_weight_oz?: number | null
49-
}>): number {
50-
const totalOz = items.reduce((acc, item) => {
51-
const weightOz =
52-
(item.variant_weight_oz && item.variant_weight_oz > 0)
53-
? item.variant_weight_oz
54-
: (item.product_weight_oz && item.product_weight_oz > 0)
55-
? item.product_weight_oz
56-
: 2 // default 2oz fallback
57-
return acc + weightOz * item.quantity
58-
}, 0)
59-
return totalOz / 16
45+
export function calculateTotalWeightLb(
46+
items: Array<{
47+
quantity: number;
48+
variant_weight_oz: number | null;
49+
product_weight_oz: number | null;
50+
}>
51+
): number {
52+
let totalOz = 0;
53+
54+
for (const item of items) {
55+
// Validate quantity
56+
const qty = Math.max(1, item.quantity || 1);
57+
58+
// Get weight with validation
59+
let weightOz = item.variant_weight_oz ?? item.product_weight_oz ?? 2;
60+
61+
// Safety checks
62+
if (isNaN(weightOz) || weightOz <= 0) {
63+
console.warn(`Invalid weight detected for item, using 2oz fallback:`, item);
64+
weightOz = 2;
65+
}
66+
67+
// Cap at 160oz (10 lbs) per item to prevent calculation errors
68+
if (weightOz > 160) {
69+
console.warn(`Unusually heavy item detected (${weightOz}oz), capping at 160oz`);
70+
weightOz = 160;
71+
}
72+
73+
totalOz += weightOz * qty;
74+
}
75+
76+
// Convert to pounds
77+
const totalLbs = totalOz / 16;
78+
79+
// Log for debugging
80+
console.log(`[Weight Calc] Total: ${totalOz}oz = ${totalLbs.toFixed(2)} lbs from ${items.length} items`);
81+
82+
return totalLbs;
6083
}
6184

6285
export function calculateShippingRate(
@@ -65,12 +88,12 @@ export function calculateShippingRate(
6588
config: any,
6689
type: 'standard' | 'express' | 'intl_standard' | 'intl_express'
6790
): { cost: number; name: string; minDays: number; maxDays: number } {
68-
// Free shipping check (US only)
91+
// Free shipping check (US domestic only)
6992
const freeThreshold = parseFloat(config.free_shipping_threshold ?? '100');
7093
if (type === 'standard' && subtotal >= freeThreshold) {
7194
return {
7295
cost: 0,
73-
name: config.standard_label || 'Free Standard Shipping',
96+
name: config.standard_label || 'Free Standard Shipping 🎁',
7497
minDays: 3,
7598
maxDays: 5,
7699
};
@@ -85,33 +108,35 @@ export function calculateShippingRate(
85108
switch (type) {
86109
case 'standard':
87110
brackets = config.weight_brackets || DEFAULT_US_STANDARD_BRACKETS;
88-
name = config.standard_label || 'USPS Ground Advantage (3-5 Days)';
111+
name = config.standard_label || 'USPS Ground Advantage (US)';
89112
minDays = 3;
90113
maxDays = 5;
91114
break;
92115
case 'express':
93116
brackets = config.express_weight_brackets || DEFAULT_US_EXPRESS_BRACKETS;
94-
name = config.express_label || 'USPS Priority Mail (1-3 Days)';
117+
name = config.express_label || 'USPS Priority Mail (US)';
95118
minDays = 1;
96119
maxDays = 3;
97120
break;
98121
case 'intl_standard':
99122
brackets = config.intl_weight_brackets || DEFAULT_INTL_STANDARD_BRACKETS;
100-
name = 'USPS Priority Mail International';
123+
name = 'USPS Priority Mail International 🌍';
101124
minDays = 6;
102125
maxDays = 10;
103126
break;
104127
case 'intl_express':
105128
brackets = config.intl_express_weight_brackets || DEFAULT_INTL_EXPRESS_BRACKETS;
106-
name = 'USPS Priority Mail Express International';
129+
name = 'USPS Priority Mail Express International 🚀';
107130
minDays = 3;
108131
maxDays = 5;
109132
break;
110133
}
111134

112-
// Find matching bracket (first bracket where weight <= max_lb)
135+
// Find matching bracket
113136
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));
137+
const cost = matchingBracket
138+
? parseFloat(String(matchingBracket.rate))
139+
: parseFloat(String(brackets[brackets.length - 1]?.rate ?? 15.99));
115140

116141
return { cost, name, minDays, maxDays };
117142
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
-- Set default 2oz weight for products missing weight
2+
UPDATE products
3+
SET weight_oz = 2.0
4+
WHERE (weight_oz IS NULL OR weight_oz = 0 OR weight_oz < 0.1)
5+
AND status = 'active';
6+
7+
-- Set default 2oz weight for variants missing weight
8+
UPDATE product_variants
9+
SET weight = 2.0
10+
WHERE (weight IS NULL OR weight = 0 OR weight < 0.1)
11+
AND status = 'active';
12+
13+
-- Add helpful comment to weight columns
14+
COMMENT ON COLUMN products.weight_oz IS 'Product weight in OUNCES';
15+
COMMENT ON COLUMN product_variants.weight IS 'Variant weight in OUNCES';

0 commit comments

Comments
 (0)