Convex backend (AUDIT-5–10): - schema: add returnLabelUrl, returnTrackingNumber, returnCarrier fields + by_return_tracking_number_and_carrier and by_stripe_payment_intent_id indexes - orders: markReturnReceived now sets status="completed"; add getOrderByPaymentIntent and applyReturnAccepted internal helpers - returnActions: add acceptReturn action — creates Shippo return label (is_return:true), persists label data, sends return label email to customer - stripeActions: handle refund.updated webhook to auto-mark orders refunded via Stripe Dashboard - shippoWebhook: add getOrderByReturnTracking; applyTrackingUpdate extended with isReturnTracking flag (return events use return_tracking_update type, skip delivered transition) - emails: add sendReturnLabelEmail; fulfillmentActions: createShippingLabel action Admin UI (AUDIT-1–6): - OrderActionsBar: full rewrite per authoritative action matrix; remove UpdateStatusDialog; add AcceptReturnButton for delivered+returnRequested state - AcceptReturnButton: new action component matching CreateLabelButton pattern - FulfilmentCard: add returnLabelUrl prop; show "Return label" row; rename outbound label to "Outbound label" when both are present - statusConfig: add return_accepted to OrderEventType and EVENT_TYPE_LABELS - orders detail page and all supporting cards/components Storefront & shared (TS fixes): - checkout/success, CheckoutErrorState, OrderReviewStep, PaymentStep: replace Button as/color/isLoading/variant="flat" with HeroUI v3-compatible props - ReviewList: isLoading → isPending for HeroUI v3 Button - packages/utils: add return and completed entries to ORDER_STATUS_LABELS/COLORS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
234 lines
7.3 KiB
TypeScript
234 lines
7.3 KiB
TypeScript
// ─── Locale ───────────────────────────────────────────────────────────────────
|
|
|
|
export const APP_LOCALE = "en-GB";
|
|
export const APP_CURRENCY = "GBP";
|
|
|
|
// ─── Currency ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Format cents to a currency string e.g. 1999 -> "£19.99"
|
|
*/
|
|
export function formatPrice(
|
|
cents: number,
|
|
currency = APP_CURRENCY,
|
|
locale = APP_LOCALE
|
|
): string {
|
|
return new Intl.NumberFormat(locale, {
|
|
style: "currency",
|
|
currency,
|
|
minimumFractionDigits: 2,
|
|
}).format(cents / 100);
|
|
}
|
|
|
|
/**
|
|
* Convert dollars to cents for storing in DB e.g. 19.99 -> 1999
|
|
*/
|
|
export function dollarsToCents(dollars: number): number {
|
|
return Math.round(dollars * 100);
|
|
}
|
|
|
|
/**
|
|
* Convert cents to dollars e.g. 1999 -> 19.99
|
|
*/
|
|
export function centsToDollars(cents: number): number {
|
|
return cents / 100;
|
|
}
|
|
|
|
// ─── Slugs ───────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Generate a URL-safe slug from a string
|
|
* e.g. "Hello World!" -> "hello-world"
|
|
*/
|
|
export function slugify(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^\w\s-]/g, "")
|
|
.replace(/[\s_-]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
}
|
|
|
|
// ─── Dates ───────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Format an ISO date string to a readable format
|
|
* e.g. "2024-01-15T10:30:00Z" -> "Jan 15, 2024"
|
|
*/
|
|
export function formatDate(
|
|
isoString: string,
|
|
options: Intl.DateTimeFormatOptions = {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
}
|
|
): string {
|
|
return new Intl.DateTimeFormat(APP_LOCALE, options).format(
|
|
new Date(isoString)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Format an ISO date string to a relative time
|
|
* e.g. "2 hours ago", "3 days ago"
|
|
*/
|
|
export function formatRelativeTime(isoString: string): string {
|
|
const diff = Date.now() - new Date(isoString).getTime();
|
|
const seconds = Math.floor(diff / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
const days = Math.floor(hours / 24);
|
|
|
|
if (days > 0) return `${days} day${days > 1 ? "s" : ""} ago`;
|
|
if (hours > 0) return `${hours} hour${hours > 1 ? "s" : ""} ago`;
|
|
if (minutes > 0) return `${minutes} minute${minutes > 1 ? "s" : ""} ago`;
|
|
return "just now";
|
|
}
|
|
|
|
// ─── Cart Helpers ───────────────────────────────────────────────────────────
|
|
|
|
export interface CartTotalsInput {
|
|
quantity: number;
|
|
priceSnapshot?: number;
|
|
unitPrice?: number;
|
|
}
|
|
|
|
/**
|
|
* Calculate subtotal, total, and item count from cart items.
|
|
* Uses priceSnapshot or unitPrice (cents). Used in cart UI and before order creation.
|
|
*/
|
|
export function calculateCartTotals(
|
|
items: CartTotalsInput[]
|
|
): { subtotal: number; total: number; itemCount: number } {
|
|
let subtotal = 0;
|
|
let itemCount = 0;
|
|
for (const item of items) {
|
|
const price = item.priceSnapshot ?? item.unitPrice ?? 0;
|
|
subtotal += item.quantity * price;
|
|
itemCount += item.quantity;
|
|
}
|
|
return { subtotal, total: subtotal, itemCount };
|
|
}
|
|
|
|
// ─── Order Helpers ───────────────────────────────────────────────────────────
|
|
|
|
import type { OrderStatus, PaymentStatus } from "@repo/types";
|
|
|
|
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
|
|
pending: "Pending",
|
|
confirmed: "Confirmed",
|
|
processing: "Processing",
|
|
shipped: "Shipped",
|
|
delivered: "Delivered",
|
|
cancelled: "Cancelled",
|
|
refunded: "Refunded",
|
|
return: "Return Requested",
|
|
completed: "Completed",
|
|
};
|
|
|
|
export const PAYMENT_STATUS_LABELS: Record<PaymentStatus, string> = {
|
|
pending: "Pending",
|
|
paid: "Paid",
|
|
failed: "Failed",
|
|
refunded: "Refunded",
|
|
};
|
|
|
|
export const ORDER_STATUS_COLORS: Record<OrderStatus, string> = {
|
|
pending: "yellow",
|
|
confirmed: "blue",
|
|
processing: "purple",
|
|
shipped: "indigo",
|
|
delivered: "green",
|
|
cancelled: "red",
|
|
refunded: "gray",
|
|
return: "orange",
|
|
completed: "teal",
|
|
};
|
|
|
|
// ─── Validation ───────────────────────────────────────────────────────────────
|
|
|
|
export function isValidEmail(email: string): boolean {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
}
|
|
|
|
export function isValidPhone(phone: string): boolean {
|
|
return /^\+?[\d\s\-()]{7,15}$/.test(phone);
|
|
}
|
|
|
|
// ─── Pagination ───────────────────────────────────────────────────────────────
|
|
|
|
export function getPaginationRange(
|
|
page: number,
|
|
limit: number
|
|
): { from: number; to: number } {
|
|
const from = (page - 1) * limit;
|
|
const to = from + limit - 1;
|
|
return { from, to };
|
|
}
|
|
|
|
export function getTotalPages(total: number, limit: number): number {
|
|
return Math.ceil(total / limit);
|
|
}
|
|
|
|
// ─── SKU Generation ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Generate a SKU from product metadata.
|
|
* e.g. Royal Canin, Adult Dog Food, flavor: Chicken, 5kg → "ROY-CANI-ADUL-DOG-CHIC-5KG"
|
|
*/
|
|
export function generateSku(
|
|
brand: string,
|
|
productName: string,
|
|
attributes?: {
|
|
size?: string;
|
|
flavor?: string;
|
|
color?: string;
|
|
},
|
|
weight?: number,
|
|
weightUnit?: string
|
|
): string {
|
|
const clean = (str: string) =>
|
|
str
|
|
.toUpperCase()
|
|
.trim()
|
|
.replace(/[^A-Z0-9\s]/g, "")
|
|
.split(/\s+/)
|
|
.map((w) => w.slice(0, 4))
|
|
.join("-");
|
|
|
|
const parts = [
|
|
brand ? clean(brand) : null,
|
|
productName ? clean(productName) : null,
|
|
attributes?.flavor ? clean(attributes.flavor) : null,
|
|
attributes?.size ? clean(attributes.size) : null,
|
|
attributes?.color ? clean(attributes.color) : null,
|
|
weight && weightUnit ? `${weight}${weightUnit.toUpperCase()}` : null,
|
|
].filter(Boolean);
|
|
|
|
return parts.join("-");
|
|
}
|
|
|
|
// ─── Misc ─────────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Generate a short order number e.g. "ORD-4F2K9"
|
|
*/
|
|
export function generateOrderNumber(): string {
|
|
return `ORD-${Math.random().toString(36).substring(2, 7).toUpperCase()}`;
|
|
}
|
|
|
|
/**
|
|
* Truncate a string to a max length with ellipsis
|
|
*/
|
|
export function truncate(text: string, maxLength: number): string {
|
|
if (text.length <= maxLength) return text;
|
|
return `${text.substring(0, maxLength)}...`;
|
|
}
|
|
|
|
/**
|
|
* Deep clone an object safely
|
|
*/
|
|
export function deepClone<T>(obj: T): T {
|
|
return JSON.parse(JSON.stringify(obj));
|
|
}
|