E-commerce Example
Building a product catalog with Monch, featuring money fields, categories, and inventory.
Models
Product Model
typescript
// lib/models/product.model.ts
import { collection, field, Money, type ModelOf, type SerializedOf } from '@codician-team/monch';
export const Products = collection({
name: 'products',
schema: {
_id: field.id(),
name: field.string().min(1).max(200),
slug: field.string(),
description: field.string().max(5000).optional(),
price: field.money({ currency: 'USD' }),
compareAtPrice: field.money({ currency: 'USD' }).optional(),
categoryId: field.objectId(),
images: field.array(field.url()).default([]),
inventory: field.object({
quantity: field.number().int().min(0).default(0),
sku: field.string().optional(),
trackInventory: field.boolean().default(true),
}),
status: field.enum(['draft', 'active', 'archived']).default('draft'),
tags: field.array(field.string()).default([]),
},
timestamps: true,
indexes: [
{ key: { slug: 1 }, unique: true },
{ key: { categoryId: 1, status: 1 } },
{ key: { status: 1, createdAt: -1 } },
{ key: { name: 'text', description: 'text', tags: 'text' } },
],
hooks: {
beforeValidate: (doc) => ({
...doc,
slug: doc.slug || doc.name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, ''),
}),
},
methods: {
isOnSale: (doc) => doc.compareAtPrice && Money.greaterThan(doc.compareAtPrice, doc.price),
discountPercentage: (doc) => {
if (!doc.compareAtPrice) return 0;
const price = Money.toNumber(doc.price);
const compareAt = Money.toNumber(doc.compareAtPrice);
return Math.round((1 - price / compareAt) * 100);
},
isInStock: (doc) => !doc.inventory.trackInventory || doc.inventory.quantity > 0,
},
statics: {
findByCategory: (col, categoryId: string) => {
return col.find({ categoryId, status: 'active' })
.sort({ createdAt: -1 });
},
search: (col, query: string, options?: { limit?: number }) => {
return col.find({
status: 'active',
$text: { $search: query },
})
.limit(options?.limit ?? 20)
.toArray();
},
getLowStockProducts: async (col, threshold = 10) => {
return col.find({
status: 'active',
'inventory.trackInventory': true,
'inventory.quantity': { $lte: threshold },
}).toArray();
},
},
});
export type Product = ModelOf<typeof Products>;
export type SerializedProduct = SerializedOf<typeof Products>;Category Model
typescript
// lib/models/category.model.ts
import { collection, field, type ModelOf, type SerializedOf } from '@codician-team/monch';
export const Categories = collection({
name: 'categories',
schema: {
_id: field.id(),
name: field.string().min(1).max(100),
slug: field.string(),
description: field.string().max(500).optional(),
image: field.url().optional(),
parentId: field.objectId().optional(),
sortOrder: field.number().int().default(0),
},
timestamps: true,
indexes: [
{ key: { slug: 1 }, unique: true },
{ key: { parentId: 1, sortOrder: 1 } },
],
hooks: {
beforeValidate: (doc) => ({
...doc,
slug: doc.slug || doc.name.toLowerCase().replace(/\s+/g, '-'),
}),
},
statics: {
getRootCategories: (col) => {
return col.find({ parentId: { $exists: false } })
.sort({ sortOrder: 1 })
.toArray();
},
getChildren: (col, parentId: string) => {
return col.find({ parentId })
.sort({ sortOrder: 1 })
.toArray();
},
},
});
export type Category = ModelOf<typeof Categories>;
export type SerializedCategory = SerializedOf<typeof Categories>;Order Model
typescript
// lib/models/order.model.ts
import { collection, field, type ModelOf, type SerializedOf } from '@codician-team/monch';
export const Orders = collection({
name: 'orders',
schema: {
_id: field.id(),
orderNumber: field.string(),
customer: field.object({
email: field.email(),
name: field.string(),
phone: field.string().optional(),
}),
items: field.array(field.object({
productId: field.objectId(),
name: field.string(),
quantity: field.number().int().positive(),
price: field.money({ currency: 'USD' }),
})),
subtotal: field.money({ currency: 'USD' }),
tax: field.money({ currency: 'USD' }),
shipping: field.money({ currency: 'USD' }),
total: field.money({ currency: 'USD' }),
status: field.enum([
'pending', 'confirmed', 'processing',
'shipped', 'delivered', 'cancelled', 'refunded'
]).default('pending'),
shippingAddress: field.object({
line1: field.string(),
line2: field.string().optional(),
city: field.string(),
state: field.string(),
postalCode: field.string(),
country: field.string().default('US'),
}),
notes: field.string().optional(),
},
timestamps: true,
indexes: [
{ key: { orderNumber: 1 }, unique: true },
{ key: { 'customer.email': 1, createdAt: -1 } },
{ key: { status: 1, createdAt: -1 } },
],
hooks: {
beforeValidate: async (doc) => ({
...doc,
orderNumber: doc.orderNumber || await generateOrderNumber(),
}),
},
statics: {
findByCustomer: (col, email: string) => {
return col.find({ 'customer.email': email })
.sort({ createdAt: -1 });
},
getRecentOrders: (col, days = 7) => {
const since = new Date();
since.setDate(since.getDate() - days);
return col.find({ createdAt: { $gte: since } })
.sort({ createdAt: -1 });
},
},
});
async function generateOrderNumber(): Promise<string> {
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
const random = Math.random().toString(36).substring(2, 8).toUpperCase();
return `ORD-${date}-${random}`;
}
export type Order = ModelOf<typeof Orders>;
export type SerializedOrder = SerializedOf<typeof Orders>;Product Listing Page
typescript
// app/shop/page.tsx
import { Products, Categories } from '@/lib/models';
import { ProductGrid } from '@/components/ProductGrid';
import { CategoryNav } from '@/components/CategoryNav';
interface Props {
searchParams: { category?: string; page?: string; q?: string };
}
export default async function ShopPage({ searchParams }: Props) {
const page = Number(searchParams.page) || 1;
const categorySlug = searchParams.category;
const query = searchParams.q;
// Build filter
let filter: any = { status: 'active' };
if (categorySlug) {
const category = await Categories.findOne({ slug: categorySlug });
if (category) {
filter.categoryId = category._id;
}
}
if (query) {
filter.$text = { $search: query };
}
// Fetch products with pagination
const result = await Products.find(filter)
.sort({ createdAt: -1 })
.paginate({ page, limit: 12 });
const products = result.data.map(p => p.serialize());
// Fetch categories for navigation
const categories = await Categories.find()
.sort({ sortOrder: 1 })
.serialize();
return (
<div className="flex gap-8">
<aside className="w-64">
<CategoryNav
categories={categories}
activeSlug={categorySlug}
/>
</aside>
<main className="flex-1">
<ProductGrid products={products} />
<Pagination {...result.pagination} />
</main>
</div>
);
}Product Detail Page
typescript
// app/shop/[slug]/page.tsx
import { Products, Categories, Money } from '@/lib/models';
import { notFound } from 'next/navigation';
import { AddToCartButton } from '@/components/AddToCartButton';
interface Props {
params: { slug: string };
}
export default async function ProductPage({ params }: Props) {
const product = await Products.findOne({
slug: params.slug,
status: 'active',
});
if (!product) {
notFound();
}
const category = await Categories.findById(product.categoryId);
const serialized = product.serialize();
// Format prices for display
const priceDisplay = Money.format(product.price);
const compareAtDisplay = product.compareAtPrice
? Money.format(product.compareAtPrice)
: null;
return (
<div className="grid md:grid-cols-2 gap-8">
<div className="space-y-4">
{serialized.images.map((image, i) => (
<img key={i} src={image} alt={serialized.name} />
))}
</div>
<div>
<h1 className="text-3xl font-bold">{serialized.name}</h1>
<div className="mt-4">
{product.isOnSale() ? (
<>
<span className="text-2xl font-bold text-red-600">
{priceDisplay}
</span>
<span className="ml-2 text-gray-500 line-through">
{compareAtDisplay}
</span>
<span className="ml-2 bg-red-100 text-red-600 px-2 py-1 rounded">
{product.discountPercentage()}% OFF
</span>
</>
) : (
<span className="text-2xl font-bold">
{priceDisplay}
</span>
)}
</div>
<p className="mt-4 text-gray-600">{serialized.description}</p>
<div className="mt-6">
{product.isInStock() ? (
<AddToCartButton productId={serialized._id} />
) : (
<button disabled className="btn-disabled">
Out of Stock
</button>
)}
</div>
</div>
</div>
);
}Checkout with Transactions
typescript
// app/actions/checkout.actions.ts
'use server';
import { Products, Orders } from '@/lib/models';
import { Money, MonchValidationError } from '@codician-team/monch';
interface CartItem {
productId: string;
quantity: number;
}
interface CheckoutData {
customer: {
email: string;
name: string;
phone?: string;
};
shippingAddress: {
line1: string;
line2?: string;
city: string;
state: string;
postalCode: string;
};
items: CartItem[];
}
export async function checkout(data: CheckoutData) {
try {
return await Products.transaction(async (session) => {
// Fetch and validate products
const products = await Promise.all(
data.items.map(async (item) => {
const product = await Products.findById(item.productId);
if (!product) {
throw new Error(`Product ${item.productId} not found`);
}
if (!product.isInStock()) {
throw new Error(`${product.name} is out of stock`);
}
if (product.inventory.quantity < item.quantity) {
throw new Error(`Not enough ${product.name} in stock`);
}
return { product, quantity: item.quantity };
})
);
// Calculate line totals using Money utility
const orderItems = products.map(({ product, quantity }) => ({
productId: product._id,
name: product.name,
quantity,
price: product.price,
lineTotal: Money.multiply(product.price, quantity),
}));
// Calculate totals
const subtotal = Money.sum(orderItems.map(item => item.lineTotal));
const tax = Money.percentage(subtotal, 8); // 8% tax
const shipping = Money.greaterThanOrEqual(subtotal, 100)
? Money.from(0, 'USD')
: Money.from(9.99, 'USD'); // Free shipping over $100
const total = Money.round(Money.sum([subtotal, tax, shipping]));
// Update inventory
for (const { product, quantity } of products) {
await Products.updateOne(
{ _id: product._id },
{ $inc: { 'inventory.quantity': -quantity } },
{ session }
);
}
// Create order
const order = await Orders.insertOne({
customer: data.customer,
shippingAddress: data.shippingAddress,
items: orderItems.map(({ productId, name, quantity, price }) => ({
productId, name, quantity, price,
})),
subtotal,
tax,
shipping,
total,
}, { session });
return { success: true, order: order.serialize() };
});
} catch (error) {
if (error instanceof MonchValidationError) {
return { success: false, errors: error.toFormErrors() };
}
return { success: false, error: (error as Error).message };
}
}Admin Dashboard Stats
typescript
// app/admin/dashboard/page.tsx
import { Products, Orders } from '@/lib/models';
export default async function AdminDashboard() {
const [
totalProducts,
activeProducts,
lowStockProducts,
recentOrders,
orderStats,
] = await Promise.all([
Products.count(),
Products.count({ status: 'active' }),
Products.getLowStockProducts(5),
Orders.getRecentOrders(7).limit(10).serialize(),
Orders.aggregate([
{ $match: { createdAt: { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } } },
{
$group: {
_id: '$status',
count: { $sum: 1 },
revenue: { $sum: '$total.amount' },
},
},
]).toArray(),
]);
return (
<div className="space-y-8">
<div className="grid grid-cols-4 gap-4">
<StatCard title="Total Products" value={totalProducts} />
<StatCard title="Active Products" value={activeProducts} />
<StatCard title="Low Stock" value={lowStockProducts.length} alert />
<StatCard title="Orders (30d)" value={orderStats.reduce((a, s) => a + s.count, 0)} />
</div>
<div className="grid grid-cols-2 gap-8">
<LowStockAlert products={lowStockProducts.map(p => p.serialize())} />
<RecentOrdersList orders={recentOrders} />
</div>
</div>
);
}