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:
18
packages/convex/package.json
Normal file
18
packages/convex/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@repo/convex",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./provider": "./src/provider.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/types": "*",
|
||||
"convex": "*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19.0.0"
|
||||
}
|
||||
}
|
||||
1
packages/convex/src/index.ts
Normal file
1
packages/convex/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ConvexClientProvider } from "./provider";
|
||||
20
packages/convex/src/provider.tsx
Normal file
20
packages/convex/src/provider.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { ConvexReactClient } from "convex/react";
|
||||
import { ConvexProviderWithClerk } from "convex/react-clerk";
|
||||
import { useAuth } from "@clerk/nextjs";
|
||||
|
||||
if (!process.env.NEXT_PUBLIC_CONVEX_URL) {
|
||||
throw new Error("Missing NEXT_PUBLIC_CONVEX_URL");
|
||||
}
|
||||
|
||||
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL);
|
||||
|
||||
export function ConvexClientProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
|
||||
{children}
|
||||
</ConvexProviderWithClerk>
|
||||
);
|
||||
}
|
||||
10
packages/types/package.json
Normal file
10
packages/types/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "@repo/types",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
218
packages/types/src/index.ts
Normal file
218
packages/types/src/index.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
// ─── User & Auth ────────────────────────────────────────────────────────────
|
||||
|
||||
export type UserRole = "customer" | "admin" | "super_admin";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
avatar_url: string | null;
|
||||
role: UserRole;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CustomerProfile {
|
||||
id: string;
|
||||
user_id: string;
|
||||
phone: string | null;
|
||||
date_of_birth: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ─── Address ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Address {
|
||||
id: string;
|
||||
user_id: string;
|
||||
full_name: string;
|
||||
phone: string;
|
||||
address_line1: string;
|
||||
address_line2: string | null;
|
||||
city: string;
|
||||
state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type AddressInput = Omit<Address, "id" | "user_id" | "created_at">;
|
||||
|
||||
// ─── Product & Catalog ───────────────────────────────────────────────────────
|
||||
|
||||
export type ProductStatus = "active" | "draft" | "archived";
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
image_url: string | null;
|
||||
parent_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
status: ProductStatus;
|
||||
category_id: string;
|
||||
category?: Category;
|
||||
images: ProductImage[];
|
||||
variants: ProductVariant[];
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProductImage {
|
||||
id: string;
|
||||
product_id: string;
|
||||
url: string;
|
||||
alt: string | null;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface ProductVariant {
|
||||
id: string;
|
||||
product_id: string;
|
||||
name: string; // e.g. "Red / XL"
|
||||
sku: string;
|
||||
price: number; // stored in cents e.g. 1999 = $19.99
|
||||
compare_at_price: number | null;
|
||||
stock_quantity: number;
|
||||
attributes: Record<string, string>; // { color: "Red", size: "XL" }
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export type ProductInput = Omit<Product, "id" | "images" | "variants" | "category" | "created_at" | "updated_at">;
|
||||
|
||||
/** Same shape as Product with optional highlights for future relevance snippets. */
|
||||
export type ProductSearchResult = Product & {
|
||||
highlights?: Record<string, string>;
|
||||
};
|
||||
|
||||
// ─── Cart ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CartItem {
|
||||
id: string;
|
||||
variant_id: string;
|
||||
variant?: ProductVariant;
|
||||
product?: Product;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
items: CartItem[];
|
||||
subtotal: number;
|
||||
total_items: number;
|
||||
}
|
||||
|
||||
/** Enriched cart line returned by Convex carts.get (variantId, quantity, priceSnapshot, productName, variantName, imageUrl, stock) */
|
||||
export interface CartLineEnriched {
|
||||
variantId: string;
|
||||
productId: string;
|
||||
quantity: number;
|
||||
priceSnapshot: number;
|
||||
productName: string;
|
||||
variantName: string;
|
||||
imageUrl?: string;
|
||||
stockQuantity?: number;
|
||||
}
|
||||
|
||||
// ─── Orders ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type OrderStatus =
|
||||
| "pending"
|
||||
| "confirmed"
|
||||
| "processing"
|
||||
| "shipped"
|
||||
| "delivered"
|
||||
| "cancelled"
|
||||
| "refunded";
|
||||
|
||||
export type PaymentStatus = "pending" | "paid" | "failed" | "refunded";
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
order_number: string;
|
||||
user_id: string;
|
||||
user?: User;
|
||||
status: OrderStatus;
|
||||
payment_status: PaymentStatus;
|
||||
items: OrderItem[];
|
||||
shipping_address: Address;
|
||||
subtotal: number; // in cents
|
||||
shipping_cost: number; // in cents
|
||||
discount: number; // in cents
|
||||
total: number; // in cents
|
||||
notes: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
order_id: string;
|
||||
variant_id: string;
|
||||
variant?: ProductVariant;
|
||||
product_name: string; // snapshot at time of order
|
||||
variant_name: string; // snapshot at time of order
|
||||
quantity: number;
|
||||
unit_price: number; // in cents, snapshot at time of order
|
||||
total_price: number; // in cents
|
||||
}
|
||||
|
||||
// ─── Pagination & API Responses ───────────────────────────────────────────────
|
||||
|
||||
export interface PaginationParams {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ─── Filters ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProductFilters {
|
||||
category_id?: string;
|
||||
status?: ProductStatus;
|
||||
min_price?: number;
|
||||
max_price?: number;
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface OrderFilters {
|
||||
status?: OrderStatus;
|
||||
payment_status?: PaymentStatus;
|
||||
user_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// ─── Dashboard / Analytics ───────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardStats {
|
||||
total_revenue: number;
|
||||
total_orders: number;
|
||||
total_customers: number;
|
||||
total_products: number;
|
||||
revenue_change_percent: number;
|
||||
orders_change_percent: number;
|
||||
}
|
||||
13
packages/utils/package.json
Normal file
13
packages/utils/package.json
Normal 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
191
packages/utils/src/index.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user