feat: initial commit — storefront, convex backend, and shared packages

Completes the first milestone of The Pet Loft ecommerce platform:
- apps/storefront: full customer-facing Next.js app with HeroUI (cart,
  checkout, orders, wishlist, product detail, shop, search, auth)
- convex/: serverless backend with schema, queries, mutations, actions,
  HTTP routes, Stripe/Shippo integrations, and co-located tests
- packages/types, packages/utils, packages/convex: shared workspace packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 09:31:18 +03:00
commit cc15338ad9
361 changed files with 45005 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
{
"name": "@repo/utils",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@repo/types": "*"
}
}

191
packages/utils/src/index.ts Normal file
View File

@@ -0,0 +1,191 @@
// ─── 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",
};
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",
};
// ─── 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);
}
// ─── 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));
}