Skip to content

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>
  );
}

Released under the MIT License.