());
+
+ const [wishlistIds, allRelated] = await Promise.all([
+ wishlistPromise,
+ getProducts({ category: product.category?.slug ?? undefined }),
+ ]);
+ const inWishlist = wishlistIds.has(product.id);
+
+ const relatedProducts = allRelated
.filter((item) => item.id !== product.id)
.slice(0, 3);
@@ -56,9 +74,14 @@ export default async function ProductDetailPage({
-
- {product.category?.name}
-
+
+
+ {product.category?.name}
+
+ {viewerId ? (
+
+ ) : null}
+
{product.name}
{product.description}
@@ -94,7 +117,12 @@ export default async function ProductDetailPage({
{relatedProducts.length ? (
{relatedProducts.map((item) => (
-
+
))}
) : (
diff --git a/e-commerce/app/products/page.tsx b/e-commerce/app/products/page.tsx
index 2b848dc..a9fb17c 100644
--- a/e-commerce/app/products/page.tsx
+++ b/e-commerce/app/products/page.tsx
@@ -4,7 +4,8 @@ import { ProductCard } from '@/components/product-card';
import { ProductsSearch } from '@/components/products-search';
import { SiteFooter } from '@/components/site-footer';
import { SiteHeader } from '@/components/site-header';
-import { getCategories, getProducts } from '@/lib/store';
+import { getCurrentAuthState } from '@/lib/auth-state';
+import { getCategories, getProducts, getWishlistProductIds } from '@/lib/store';
export const dynamic = 'force-dynamic';
@@ -14,10 +15,22 @@ export default async function ProductsPage({
searchParams: Promise<{ category?: string; search?: string }>;
}) {
const params = await searchParams;
- const [categories, products] = await Promise.all([
+ const authPromise = getCurrentAuthState();
+ const wishlistPromise = authPromise.then((state) =>
+ state.viewer.isAuthenticated && state.viewer.id && state.accessToken
+ ? getWishlistProductIds({ accessToken: state.accessToken, userId: state.viewer.id }).catch(
+ () => new Set
(),
+ )
+ : new Set(),
+ );
+
+ const [authState, categories, products, wishlistIds] = await Promise.all([
+ authPromise,
getCategories(),
getProducts({ category: params.category, search: params.search }),
+ wishlistPromise,
]);
+ const showWishlist = authState.viewer.isAuthenticated && !!authState.viewer.id;
const activeCategory = params.category ?? null;
function buildCatalogHref(category?: string) {
@@ -83,6 +96,8 @@ export default async function ProductsPage({
product={product}
imageFetchPriority={index === 0 ? 'high' : 'auto'}
imageLoading={index === 0 ? 'eager' : 'lazy'}
+ showWishlist={showWishlist}
+ inWishlist={wishlistIds.has(product.id)}
/>
))}
diff --git a/e-commerce/components/account-dropdown.tsx b/e-commerce/components/account-dropdown.tsx
index 3d85537..ddfd233 100644
--- a/e-commerce/components/account-dropdown.tsx
+++ b/e-commerce/components/account-dropdown.tsx
@@ -1,7 +1,7 @@
'use client';
import Link from 'next/link';
-import { ChevronDown, LogOut, Package2, UserRound } from 'lucide-react';
+import { ChevronDown, Heart, LogOut, Package2, UserRound } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { signOut } from '@/lib/auth-actions';
import { cn } from '@/lib/utils';
@@ -93,6 +93,15 @@ export function AccountDropdown({
Orders
+ setIsOpen(false)}
+ role="menuitem"
+ >
+
+ Wishlist
+
{
+ try {
+ await markOrderShippedAction({ orderId, trackingNumber: tracking });
+ toast.success('Order marked as shipped');
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Unable to mark as shipped');
+ }
+ });
+ }
+
+ function handleDeliver() {
+ startTransition(async () => {
+ try {
+ await markOrderDeliveredAction({ orderId });
+ toast.success('Order marked as delivered');
+ } catch (err) {
+ toast.error(err instanceof Error ? err.message : 'Unable to mark as delivered');
+ }
+ });
+ }
+
+ return (
+
+
+
Admin actions
+
Update fulfillment
+
+
+ {canShip ? (
+
+
+ Tracking number (optional)
+ setTracking(event.target.value)}
+ />
+
+
+ {pending ? : }
+ Mark as shipped
+
+
+ ) : null}
+
+ {canDeliver ? (
+
+
+ Shipped on {' '}
+ {currentTracking ? {currentTracking} : 'no tracking number'}
+
+
+ {pending ? : }
+ Mark as delivered
+
+
+ ) : null}
+
+ );
+}
diff --git a/e-commerce/components/checkout-form.tsx b/e-commerce/components/checkout-form.tsx
index 43f9cd2..ca2c283 100644
--- a/e-commerce/components/checkout-form.tsx
+++ b/e-commerce/components/checkout-form.tsx
@@ -3,7 +3,6 @@
import { Loader2 } from 'lucide-react';
import { startTransition, useState } from 'react';
import { toast } from 'sonner';
-import { useRouter } from 'next/navigation';
import {
ADDRESS_FORM_FIELDS,
createEmptyAddressFields,
@@ -15,7 +14,6 @@ import type { SavedAddress } from '@/lib/types';
import { Button } from '@/components/ui/button';
export function CheckoutForm({ addresses }: { addresses: SavedAddress[] }) {
- const router = useRouter();
const [selectedAddressId, setSelectedAddressId] = useState(
addresses.find((address) => address.is_default_shipping)?.id ?? addresses[0]?.id ?? null,
);
@@ -30,15 +28,14 @@ export function CheckoutForm({ addresses }: { addresses: SavedAddress[] }) {
startTransition(async () => {
try {
- const { orderId } = await placeOrderAction({
+ const { checkoutUrl } = await placeOrderAction({
addressId: useNewAddress ? undefined : selectedAddressId ?? undefined,
address: useNewAddress ? fields : undefined,
note: note.trim() || undefined,
});
- router.push(`/account/orders?placed=${orderId}`);
- router.refresh();
+ window.location.assign(checkoutUrl);
} catch (error) {
- toast.error(error instanceof Error ? error.message : 'Unable to place order.');
+ toast.error(error instanceof Error ? error.message : 'Unable to start checkout.');
setIsPending(false);
}
});
@@ -119,7 +116,7 @@ export function CheckoutForm({ addresses }: { addresses: SavedAddress[] }) {
{isPending ? : null}
- {isPending ? 'Placing order' : 'Place order'}
+ {isPending ? 'Redirecting to checkout' : 'Place order'}
);
diff --git a/e-commerce/components/order-timeline.tsx b/e-commerce/components/order-timeline.tsx
new file mode 100644
index 0000000..564cc33
--- /dev/null
+++ b/e-commerce/components/order-timeline.tsx
@@ -0,0 +1,53 @@
+import { format } from 'date-fns';
+import { CheckCircle2, Circle, Clock } from 'lucide-react';
+import type { OrderStatusEvent, OrderStatusEventType } from '@/lib/types';
+
+const STEPS: { type: OrderStatusEventType; label: string }[] = [
+ { type: 'order_placed', label: 'Order placed' },
+ { type: 'payment_succeeded', label: 'Payment confirmed' },
+ { type: 'fulfillment_processing', label: 'Preparing' },
+ { type: 'fulfillment_shipped', label: 'Shipped' },
+ { type: 'fulfillment_delivered', label: 'Delivered' },
+];
+
+export function OrderTimeline({ events }: { events: OrderStatusEvent[] }) {
+ const reached = new Set(events.map((e) => e.event_type));
+ const eventByType = new Map(events.map((e) => [e.event_type, e]));
+
+ const firstUnreachedIndex = STEPS.findIndex((s) => !reached.has(s.type));
+
+ return (
+
+ {STEPS.map((step, index) => {
+ const event = eventByType.get(step.type);
+ const isDone = reached.has(step.type);
+ const isCurrent = !isDone && index === firstUnreachedIndex;
+
+ return (
+
+
+ {isDone ? (
+
+ ) : isCurrent ? (
+
+ ) : (
+
+ )}
+
+
+
+ {step.label}
+
+ {event ? (
+
+ {format(new Date(event.created_at), 'MMM d, yyyy ยท h:mm a')}
+ {event.message ? `, ${event.message}` : ''}
+
+ ) : null}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/e-commerce/components/product-card.tsx b/e-commerce/components/product-card.tsx
index b727a20..f6bb525 100644
--- a/e-commerce/components/product-card.tsx
+++ b/e-commerce/components/product-card.tsx
@@ -1,5 +1,6 @@
import Image from 'next/image';
import Link from 'next/link';
+import { WishlistButton } from '@/components/wishlist-button';
import type { Product } from '@/lib/types';
import { formatCurrency } from '@/lib/utils';
@@ -7,10 +8,14 @@ export function ProductCard({
product,
imageLoading = 'lazy',
imageFetchPriority = 'auto',
+ inWishlist = false,
+ showWishlist = false,
}: {
product: Product;
imageLoading?: 'lazy' | 'eager';
imageFetchPriority?: 'auto' | 'high' | 'low';
+ inWishlist?: boolean;
+ showWishlist?: boolean;
}) {
return (
@@ -26,6 +31,15 @@ export function ProductCard({
fetchPriority={imageFetchPriority}
/>
) : null}
+ {showWishlist ? (
+
+
+
+ ) : null}
diff --git a/e-commerce/components/wishlist-button.tsx b/e-commerce/components/wishlist-button.tsx
new file mode 100644
index 0000000..7662c98
--- /dev/null
+++ b/e-commerce/components/wishlist-button.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { Heart } from 'lucide-react';
+import { useOptimistic, useTransition } from 'react';
+import { toast } from 'sonner';
+import { toggleWishlistAction } from '@/lib/wishlist-actions';
+import { cn } from '@/lib/utils';
+
+export function WishlistButton({
+ productId,
+ initialInWishlist,
+ size = 'md',
+}: {
+ productId: string;
+ initialInWishlist: boolean;
+ size?: 'sm' | 'md';
+}) {
+ const [optimistic, setOptimistic] = useOptimistic(initialInWishlist);
+ const [isPending, startTransition] = useTransition();
+
+ function handleClick(event: React.MouseEvent) {
+ event.preventDefault();
+ event.stopPropagation();
+ startTransition(async () => {
+ const next = !optimistic;
+ setOptimistic(next);
+ try {
+ await toggleWishlistAction(productId, optimistic);
+ } catch (err) {
+ setOptimistic(!next);
+ toast.error(err instanceof Error ? err.message : 'Unable to update wishlist.');
+ }
+ });
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/e-commerce/lib/store-actions.ts b/e-commerce/lib/store-actions.ts
index b8c45ce..c403176 100644
--- a/e-commerce/lib/store-actions.ts
+++ b/e-commerce/lib/store-actions.ts
@@ -8,7 +8,11 @@ import {
import {
addItemToCart,
createAddress,
+ createCheckoutSessionForOrder,
deleteSavedAddress,
+ getOrderPaymentState,
+ markOrderDelivered,
+ markOrderShipped,
placeOrderForUser,
removeCartItem,
setDefaultSavedAddress,
@@ -70,11 +74,22 @@ export async function placeOrderAction(payload: {
note: payload.note,
});
+ const origin = process.env.NODE_ENV === 'development'
+ ? 'http://localhost:3000'
+ : process.env.NEXT_PUBLIC_APP_URL!;
+
+ const checkoutUrl = await createCheckoutSessionForOrder({
+ accessToken,
+ userId: viewer.id,
+ userEmail: viewer.email,
+ orderId,
+ successOrigin: origin,
+ });
+
revalidatePath('/cart');
- revalidatePath('/checkout');
revalidatePath('/account/orders');
- return { orderId };
+ return { orderId, checkoutUrl };
}
export async function createSavedAddressAction(input: AddressInput) {
@@ -119,3 +134,38 @@ export async function setDefaultAddressAction(
revalidatePath('/account/profile');
revalidatePath('/checkout');
}
+
+export async function pollOrderPaymentAction(payload: { orderId: string }) {
+ const { viewer, accessToken } = await requireSession();
+ const result = await getOrderPaymentState({
+ accessToken,
+ userId: viewer.id,
+ orderId: payload.orderId,
+ });
+ if (result.paid) {
+ revalidatePath('/account/orders');
+ revalidatePath(`/account/orders/${payload.orderId}`);
+ }
+ return result;
+}
+
+export async function markOrderShippedAction(payload: {
+ orderId: string;
+ trackingNumber?: string;
+}) {
+ const { accessToken } = await requireSession();
+ await markOrderShipped({
+ accessToken,
+ orderId: payload.orderId,
+ trackingNumber: payload.trackingNumber?.trim() || null,
+ });
+ revalidatePath('/account/orders');
+ revalidatePath(`/account/orders/${payload.orderId}`);
+}
+
+export async function markOrderDeliveredAction(payload: { orderId: string }) {
+ const { accessToken } = await requireSession();
+ await markOrderDelivered({ accessToken, orderId: payload.orderId });
+ revalidatePath('/account/orders');
+ revalidatePath(`/account/orders/${payload.orderId}`);
+}
diff --git a/e-commerce/lib/store.ts b/e-commerce/lib/store.ts
index 9cf614c..875eddb 100644
--- a/e-commerce/lib/store.ts
+++ b/e-commerce/lib/store.ts
@@ -10,12 +10,14 @@ import type {
CartItem,
Category,
Order,
+ OrderStatusEvent,
Product,
ProductOption,
ProductOptionValue,
ProductVariant,
SavedAddress,
ShoppingCart,
+ WishlistItem,
} from '@/lib/types';
type InsforgeClient = ReturnType;
@@ -473,6 +475,74 @@ export async function removeCartItem(accessToken: string, itemId: string) {
assertNoDatabaseError(error, 'Unable to remove cart item.');
}
+export async function getWishlistProductIds(args: {
+ accessToken: string;
+ userId: string;
+}): Promise> {
+ const insforge = getInsforge(args.accessToken);
+ const { data, error } = await insforge.database
+ .from('wishlists')
+ .select('product_id')
+ .eq('user_id', args.userId);
+
+ assertNoDatabaseError(error, 'Unable to load wishlist.');
+ return new Set((data ?? []).map((row) => row.product_id as string));
+}
+
+export async function getWishlistWithProducts(args: {
+ accessToken: string;
+ userId: string;
+}): Promise {
+ const insforge = getInsforge(args.accessToken);
+ const { data, error } = await insforge.database
+ .from('wishlists')
+ .select('*, product:products(*)')
+ .eq('user_id', args.userId)
+ .order('created_at', { ascending: false });
+
+ assertNoDatabaseError(error, 'Unable to load wishlist.');
+ return (data ?? []) as WishlistItem[];
+}
+
+export async function addToWishlist(args: {
+ accessToken: string;
+ userId: string;
+ productId: string;
+}) {
+ const insforge = getInsforge(args.accessToken);
+ const { error } = await insforge.database
+ .from('wishlists')
+ .insert({ user_id: args.userId, product_id: args.productId });
+
+ // Tolerate the unique-constraint conflict so toggling twice doesn't blow up.
+ const errorCode = (error as { code?: string } | null)?.code;
+ const errorDetails = (error as { details?: string } | null)?.details ?? '';
+ const errorMessage = error?.message ?? '';
+ const isDuplicateKey =
+ errorCode === '23505' ||
+ errorMessage.includes('wishlists_user_product_unique') ||
+ errorDetails.includes('wishlists_user_product_unique');
+
+ if (error && !isDuplicateKey) {
+ assertNoDatabaseError(error, 'Unable to add to wishlist.');
+ }
+}
+
+export async function removeFromWishlist(args: {
+ accessToken: string;
+ userId: string;
+ productId: string;
+}) {
+ const insforge = getInsforge(args.accessToken);
+ const { error } = await insforge.database
+ .from('wishlists')
+ .delete()
+ .eq('user_id', args.userId)
+ .eq('product_id', args.productId);
+
+ assertNoDatabaseError(error, 'Unable to remove from wishlist.');
+}
+
export async function getSavedAddresses(userId: string, accessToken: string) {
const insforge = getInsforge(accessToken);
const { data, error } = await insforge.database
@@ -603,6 +673,143 @@ export async function placeOrderForUser(args: {
return data as string;
}
+export async function createCheckoutSessionForOrder(args: {
+ accessToken: string;
+ userId: string;
+ userEmail: string | null;
+ orderId: string;
+ successOrigin: string;
+}) {
+ const insforge = getInsforge(args.accessToken);
+
+ const { data: order, error: orderError } = await insforge.database
+ .from('orders')
+ .select('id, total_cents, subtotal_cents, shipping_cents, tax_cents, email')
+ .eq('id', args.orderId)
+ .eq('user_id', args.userId)
+ .single();
+
+ assertNoDatabaseError(orderError, 'Unable to load order.');
+ if (!order) throw new Error('Order not found.');
+
+ const { data: items, error: itemsError } = await insforge.database
+ .from('order_items')
+ .select('product_id, variant_id, quantity, unit_price_cents, product_name')
+ .eq('order_id', args.orderId);
+
+ assertNoDatabaseError(itemsError, 'Unable to load order items.');
+ if (!items || items.length === 0) throw new Error('Order has no items.');
+
+ const productIds = Array.from(new Set(items.map((i) => i.product_id).filter(Boolean))) as string[];
+ const variantIds = Array.from(new Set(items.map((i) => i.variant_id).filter(Boolean))) as string[];
+
+ const { data: products, error: productsError } = await insforge.database
+ .from('products')
+ .select('id, stripe_price_id')
+ .in('id', productIds);
+ assertNoDatabaseError(productsError, 'Unable to load product prices.');
+
+ const { data: variants, error: variantsError } = variantIds.length
+ ? await insforge.database
+ .from('product_variants')
+ .select('id, stripe_price_id')
+ .in('id', variantIds)
+ : { data: [], error: null };
+ assertNoDatabaseError(variantsError, 'Unable to load variant prices.');
+
+ const productPriceMap = new Map(
+ (products ?? []).map((p) => [p.id, p.stripe_price_id ?? null]),
+ );
+ const variantPriceMap = new Map(
+ (variants ?? []).map((v) => [v.id, v.stripe_price_id ?? null]),
+ );
+
+ const lineItems = items.map((item) => {
+ const priceId = item.variant_id
+ ? variantPriceMap.get(item.variant_id)
+ : productPriceMap.get(item.product_id);
+
+ if (!priceId) {
+ throw new Error(
+ `Missing Stripe price for "${item.product_name}". See the README "Configure Stripe payments" section for setup.`,
+ );
+ }
+
+ return { stripePriceId: priceId, quantity: item.quantity };
+ });
+
+ const { data: session, error: sessionError } = await insforge.payments.createCheckoutSession('test', {
+ mode: 'payment',
+ lineItems,
+ successUrl: `${args.successOrigin}/checkout/success?order_id=${args.orderId}&session_id={CHECKOUT_SESSION_ID}`,
+ cancelUrl: `${args.successOrigin}/cart`,
+ subject: { type: 'user', id: args.userId },
+ customerEmail: args.userEmail ?? order.email ?? null,
+ metadata: { order_id: args.orderId },
+ idempotencyKey: `order:${args.orderId}`,
+ });
+
+ if (sessionError) throw sessionError;
+ if (!session?.checkoutSession?.url) {
+ throw new Error('Stripe checkout session was created but no URL was returned.');
+ }
+
+ return session.checkoutSession.url;
+}
+
+export async function getOrderPaymentState(args: {
+ accessToken: string;
+ userId: string;
+ orderId: string;
+}) {
+ const insforge = getInsforge(args.accessToken);
+ const { data: order, error } = await insforge.database
+ .from('orders')
+ .select('*')
+ .eq('id', args.orderId)
+ .eq('user_id', args.userId)
+ .maybeSingle();
+
+ assertNoDatabaseError(error, 'Unable to load order.');
+ if (!order) return { paid: false as const, order: null };
+ return order.payment_status === 'paid'
+ ? { paid: true as const, order: order as Order }
+ : { paid: false as const, order: order as Order };
+}
+
+export async function isCurrentUserAdmin(accessToken: string): Promise {
+ const insforge = getInsforge(accessToken);
+ const { data, error } = await insforge.database.rpc('current_user_is_admin');
+ if (error) return false;
+ return data === true;
+}
+
+export async function markOrderShipped(args: {
+ accessToken: string;
+ orderId: string;
+ trackingNumber?: string | null;
+}) {
+ const insforge = getInsforge(args.accessToken);
+ const { data, error } = await insforge.database.rpc('mark_order_shipped', {
+ p_order_id: args.orderId,
+ p_tracking_number: args.trackingNumber ?? null,
+ });
+ assertNoDatabaseError(error, 'Unable to mark order as shipped.');
+ return data as Order;
+}
+
+export async function markOrderDelivered(args: {
+ accessToken: string;
+ orderId: string;
+}) {
+ const insforge = getInsforge(args.accessToken);
+ const { data, error } = await insforge.database.rpc('mark_order_delivered', {
+ p_order_id: args.orderId,
+ });
+ assertNoDatabaseError(error, 'Unable to mark order as delivered.');
+ return data as Order;
+}
+
export async function getOrders(args: {
accessToken: string;
userId: string;
@@ -643,3 +850,18 @@ export async function getOrderById(args: {
assertNoDatabaseError(error, 'Unable to load order.');
return data as Order | null;
}
+
+export async function getOrderTimeline(args: {
+ accessToken: string;
+ orderId: string;
+}): Promise {
+ const insforge = getInsforge(args.accessToken);
+ const { data, error } = await insforge.database
+ .from('order_status_events')
+ .select('*')
+ .eq('order_id', args.orderId)
+ .order('created_at', { ascending: true });
+
+ assertNoDatabaseError(error, 'Unable to load order timeline.');
+ return (data ?? []) as OrderStatusEvent[];
+}
diff --git a/e-commerce/lib/types.ts b/e-commerce/lib/types.ts
index 2c18693..cea84c6 100644
--- a/e-commerce/lib/types.ts
+++ b/e-commerce/lib/types.ts
@@ -36,6 +36,7 @@ export interface Product {
featured: boolean;
created_at: string;
updated_at: string;
+ stripe_price_id?: string | null;
category?: Pick | null;
options?: ProductOption[];
variants?: ProductVariant[];
@@ -71,6 +72,7 @@ export interface ProductVariant {
is_default: boolean;
is_active: boolean;
option_value_ids: string[];
+ stripe_price_id?: string | null;
}
export interface CartItem {
@@ -137,6 +139,33 @@ export interface OrderItem {
line_total_cents: number;
}
+export type OrderStatusEventType =
+ | 'order_placed'
+ | 'payment_succeeded'
+ | 'payment_failed'
+ | 'fulfillment_processing'
+ | 'fulfillment_shipped'
+ | 'fulfillment_delivered'
+ | 'order_cancelled'
+ | 'order_refunded';
+
+export interface OrderStatusEvent {
+ id: string;
+ order_id: string;
+ user_id: string;
+ event_type: OrderStatusEventType;
+ message: string | null;
+ created_at: string;
+}
+
+export interface WishlistItem {
+ id: string;
+ user_id: string;
+ product_id: string;
+ created_at: string;
+ product?: Product;
+}
+
export interface Order {
id: string;
order_number: string;
@@ -162,5 +191,12 @@ export interface Order {
placed_at: string;
created_at: string;
updated_at: string;
+ tracking_number?: string | null;
+ shipped_at?: string | null;
+ delivered_at?: string | null;
+ stripe_checkout_session_id?: string | null;
+ stripe_payment_intent_id?: string | null;
+ discount_code?: string | null;
+ discount_cents?: number;
items?: OrderItem[];
}
diff --git a/e-commerce/lib/wishlist-actions.ts b/e-commerce/lib/wishlist-actions.ts
new file mode 100644
index 0000000..41427b5
--- /dev/null
+++ b/e-commerce/lib/wishlist-actions.ts
@@ -0,0 +1,29 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import {
+ persistRefreshedSession,
+ requireAuthenticatedSession,
+} from '@/lib/auth-session';
+import { addToWishlist, removeFromWishlist } from '@/lib/store';
+
+async function requireSession() {
+ const authState = await requireAuthenticatedSession();
+ await persistRefreshedSession(authState);
+ return { viewer: authState.viewer, accessToken: authState.accessToken };
+}
+
+export async function toggleWishlistAction(productId: string, currentlyInWishlist: boolean) {
+ const { viewer, accessToken } = await requireSession();
+
+ if (currentlyInWishlist) {
+ await removeFromWishlist({ accessToken, userId: viewer.id, productId });
+ } else {
+ await addToWishlist({ accessToken, userId: viewer.id, productId });
+ }
+
+ revalidatePath('/');
+ revalidatePath('/products');
+ revalidatePath('/account/wishlist');
+ return { inWishlist: !currentlyInWishlist };
+}
diff --git a/e-commerce/migrations/db_init.sql b/e-commerce/migrations/db_init.sql
index 3fe6093..87bf3e8 100644
--- a/e-commerce/migrations/db_init.sql
+++ b/e-commerce/migrations/db_init.sql
@@ -218,6 +218,39 @@ create table if not exists public.order_items (
constraint order_items_line_total_non_negative check (line_total_cents >= 0)
);
+create table if not exists public.order_status_events (
+ id uuid primary key default gen_random_uuid(),
+ order_id uuid not null references public.orders(id) on delete cascade,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ event_type text not null,
+ message text,
+ created_at timestamptz not null default now(),
+ constraint order_status_events_type_check check (
+ event_type in (
+ 'order_placed',
+ 'payment_succeeded',
+ 'payment_failed',
+ 'fulfillment_processing',
+ 'fulfillment_shipped',
+ 'fulfillment_delivered',
+ 'order_cancelled',
+ 'order_refunded'
+ )
+ )
+);
+
+create index if not exists order_status_events_order_idx on public.order_status_events (order_id, created_at asc);
+
+create table if not exists public.wishlists (
+ id uuid primary key default gen_random_uuid(),
+ user_id uuid not null references auth.users(id) on delete cascade,
+ product_id uuid not null references public.products(id) on delete cascade,
+ created_at timestamptz not null default now(),
+ constraint wishlists_user_product_unique unique (user_id, product_id)
+);
+
+create index if not exists wishlists_user_idx on public.wishlists (user_id, created_at desc);
+
alter table public.cart_items
add column if not exists variant_id uuid references public.product_variants(id) on delete set null;
@@ -226,6 +259,33 @@ alter table public.order_items
add column if not exists variant_title text,
add column if not exists variant_summary text;
+alter table public.products
+ add column if not exists stripe_price_id text;
+
+alter table public.product_variants
+ add column if not exists stripe_price_id text;
+
+alter table public.orders
+ add column if not exists stripe_checkout_session_id text,
+ add column if not exists stripe_payment_intent_id text,
+ add column if not exists discount_code text,
+ add column if not exists discount_cents integer not null default 0,
+ add column if not exists tracking_number text,
+ add column if not exists shipped_at timestamptz,
+ add column if not exists delivered_at timestamptz;
+
+do $$
+begin
+ if not exists (
+ select 1 from pg_constraint
+ where conname = 'orders_discount_non_negative'
+ and conrelid = 'public.orders'::regclass
+ ) then
+ alter table public.orders
+ add constraint orders_discount_non_negative check (discount_cents >= 0);
+ end if;
+end $$;
+
do $$
begin
execute 'drop function if exists public.sync_current_user_profile(text, text, text)';
@@ -270,7 +330,10 @@ create index if not exists cart_items_user_idx on public.cart_items (user_id, cr
create index if not exists saved_addresses_user_idx on public.saved_addresses (user_id, created_at desc);
create index if not exists orders_user_created_idx on public.orders (user_id, created_at desc);
create index if not exists orders_status_created_idx on public.orders (status, created_at desc);
+create index if not exists orders_stripe_session_idx on public.orders (stripe_checkout_session_id) where stripe_checkout_session_id is not null;
create index if not exists order_items_order_idx on public.order_items (order_id);
+create index if not exists products_stripe_price_idx on public.products (stripe_price_id) where stripe_price_id is not null;
+create index if not exists product_variants_stripe_price_idx on public.product_variants (stripe_price_id) where stripe_price_id is not null;
alter table public.cart_items
drop constraint if exists cart_items_unique_product_per_cart;
@@ -330,31 +393,25 @@ begin
raise exception 'You must be signed in to place an order.';
end if;
- select *
- into v_address
+ select * into v_address
from public.saved_addresses
- where id = p_address_id
- and user_id = v_user_id;
+ where id = p_address_id and user_id = v_user_id;
if not found then
raise exception 'Shipping address not found.';
end if;
- select id
- into v_cart_id
+ select id into v_cart_id
from public.shopping_carts
- where user_id = v_user_id
- and status = 'active'
- order by updated_at desc
- limit 1;
+ where user_id = v_user_id and status = 'active'
+ order by updated_at desc limit 1;
if v_cart_id is null then
raise exception 'No active cart found.';
end if;
if exists (
- select 1
- from public.cart_items ci
+ select 1 from public.cart_items ci
join public.products p on p.id = ci.product_id
left join public.product_variants pv on pv.id = ci.variant_id
where ci.cart_id = v_cart_id
@@ -368,125 +425,148 @@ begin
select coalesce(sum(quantity * unit_price_cents), 0)::integer
into v_subtotal
- from public.cart_items
- where cart_id = v_cart_id;
+ from public.cart_items where cart_id = v_cart_id;
if v_subtotal <= 0 then
raise exception 'Your cart is empty.';
end if;
- select email
- into v_email
- from auth.users
- where id = v_user_id;
+ select email into v_email from auth.users where id = v_user_id;
- v_shipping := case when v_subtotal >= 20000 then 0 else 1200 end;
- v_tax := floor(v_subtotal * 0.08)::integer;
- v_total := v_subtotal + v_shipping + v_tax;
+ v_shipping := 0;
+ v_tax := 0;
+ v_total := v_subtotal;
insert into public.orders (
- user_id,
- status,
- payment_status,
- fulfillment_status,
- email,
- shipping_name,
- shipping_phone,
- shipping_company,
- shipping_address1,
- shipping_address2,
- shipping_city,
- shipping_region,
- shipping_postal_code,
- shipping_country_code,
- notes,
- subtotal_cents,
- shipping_cents,
- tax_cents,
- total_cents
+ user_id, status, payment_status, fulfillment_status, email,
+ shipping_name, shipping_phone, shipping_company,
+ shipping_address1, shipping_address2, shipping_city,
+ shipping_region, shipping_postal_code, shipping_country_code,
+ notes, subtotal_cents, shipping_cents, tax_cents, total_cents
)
values (
- v_user_id,
- 'confirmed',
- 'paid',
- 'processing',
- coalesce(v_email, ''),
- v_address.recipient_name,
- v_address.phone,
- v_address.company,
- v_address.line1,
- v_address.line2,
- v_address.city,
- v_address.region,
- v_address.postal_code,
- v_address.country_code,
- p_note,
- v_subtotal,
- v_shipping,
- v_tax,
- v_total
+ v_user_id, 'pending', 'pending', 'unfulfilled', coalesce(v_email, ''),
+ v_address.recipient_name, v_address.phone, v_address.company,
+ v_address.line1, v_address.line2, v_address.city,
+ v_address.region, v_address.postal_code, v_address.country_code,
+ p_note, v_subtotal, v_shipping, v_tax, v_total
)
returning id into v_order_id;
insert into public.order_items (
- order_id,
- user_id,
- product_id,
- variant_id,
- product_name,
- product_slug,
- product_image_url,
- sku,
- variant_title,
- variant_summary,
- unit_price_cents,
- quantity,
- line_total_cents
+ order_id, user_id, product_id, variant_id,
+ product_name, product_slug, product_image_url, sku,
+ variant_title, variant_summary, unit_price_cents, quantity, line_total_cents
)
select
- v_order_id,
- v_user_id,
- p.id,
- ci.variant_id,
- p.name,
- p.slug,
- coalesce(pv.image_url, p.image_url),
- coalesce(pv.sku, p.sku),
- pv.title,
- pv.option_summary,
- ci.unit_price_cents,
- ci.quantity,
+ v_order_id, v_user_id, p.id, ci.variant_id,
+ p.name, p.slug, coalesce(pv.image_url, p.image_url), coalesce(pv.sku, p.sku),
+ pv.title, pv.option_summary, ci.unit_price_cents, ci.quantity,
ci.quantity * ci.unit_price_cents
from public.cart_items ci
join public.products p on p.id = ci.product_id
left join public.product_variants pv on pv.id = ci.variant_id
where ci.cart_id = v_cart_id;
+ insert into public.order_status_events (order_id, user_id, event_type, message)
+ values (v_order_id, v_user_id, 'order_placed', 'Order placed, awaiting payment');
+
+ return v_order_id;
+end;
+$$;
+
+drop function if exists public.finalize_order(uuid, text, text, text, integer);
+
+-- Fulfills the order when the Stripe webhook lands a succeeded payment.
+create or replace function public.handle_payment_succeeded()
+returns trigger
+language plpgsql
+security definer
+set search_path = public, payments
+as $$
+declare
+ v_order_id uuid;
+ v_user_id uuid;
+ v_cart_id uuid;
+begin
+ if NEW.type <> 'one_time_payment' or NEW.status <> 'succeeded' then
+ return NEW;
+ end if;
+
+ if NEW.stripe_checkout_session_id is null then
+ return NEW;
+ end if;
+
+ declare v_order_id_text text;
+ begin
+ select cs.metadata->>'order_id'
+ into v_order_id_text
+ from payments.checkout_sessions cs
+ where cs.stripe_checkout_session_id = NEW.stripe_checkout_session_id
+ limit 1;
+
+ if v_order_id_text is null or v_order_id_text !~ '^[0-9a-fA-F-]{36}$' then
+ return NEW;
+ end if;
+
+ v_order_id := v_order_id_text::uuid;
+ end;
+
+ update public.orders
+ set status = 'confirmed',
+ payment_status = 'paid',
+ fulfillment_status = 'processing',
+ stripe_checkout_session_id = NEW.stripe_checkout_session_id,
+ stripe_payment_intent_id = NEW.stripe_payment_intent_id,
+ updated_at = now()
+ where id = v_order_id
+ and payment_status <> 'paid'
+ returning user_id into v_user_id;
+
+ if v_user_id is null then
+ return NEW;
+ end if;
+
update public.products p
- set inventory_count = greatest(0, p.inventory_count - ci.quantity),
+ set inventory_count = greatest(0, p.inventory_count - oi.quantity),
updated_at = now()
- from public.cart_items ci
- where ci.cart_id = v_cart_id
- and ci.variant_id is null
- and p.id = ci.product_id;
+ from public.order_items oi
+ where oi.order_id = v_order_id
+ and oi.variant_id is null
+ and p.id = oi.product_id;
update public.product_variants pv
- set inventory_count = greatest(0, pv.inventory_count - ci.quantity),
+ set inventory_count = greatest(0, pv.inventory_count - oi.quantity),
updated_at = now()
- from public.cart_items ci
- where ci.cart_id = v_cart_id
- and ci.variant_id is not null
- and pv.id = ci.variant_id;
+ from public.order_items oi
+ where oi.order_id = v_order_id
+ and oi.variant_id is not null
+ and pv.id = oi.variant_id;
+
+ select id into v_cart_id from public.shopping_carts
+ where user_id = v_user_id and status = 'active'
+ order by updated_at desc limit 1;
+
+ if v_cart_id is not null then
+ update public.shopping_carts set status = 'converted', updated_at = now()
+ where id = v_cart_id;
+ end if;
- update public.shopping_carts
- set status = 'converted',
- updated_at = now()
- where id = v_cart_id;
+ insert into public.order_status_events (order_id, user_id, event_type, message)
+ values
+ (v_order_id, v_user_id, 'payment_succeeded', 'Payment confirmed via Stripe'),
+ (v_order_id, v_user_id, 'fulfillment_processing', 'Order is being prepared');
- return v_order_id;
+ return NEW;
end;
$$;
+drop trigger if exists on_payment_history_succeeded on payments.payment_history;
+create trigger on_payment_history_succeeded
+after insert or update on payments.payment_history
+for each row
+execute function public.handle_payment_succeeded();
+
drop trigger if exists categories_set_updated_at on public.categories;
create trigger categories_set_updated_at
before update on public.categories
@@ -552,6 +632,8 @@ alter table public.cart_items enable row level security;
alter table public.saved_addresses enable row level security;
alter table public.orders enable row level security;
alter table public.order_items enable row level security;
+alter table public.order_status_events enable row level security;
+alter table public.wishlists enable row level security;
drop policy if exists "categories_public_read" on public.categories;
create policy "categories_public_read"
@@ -746,6 +828,33 @@ to authenticated
using ((select public.current_user_is_admin()))
with check ((select public.current_user_is_admin()));
+drop policy if exists "order_status_events_owner_select" on public.order_status_events;
+create policy "order_status_events_owner_select"
+on public.order_status_events for select
+to authenticated
+using (auth.uid() = user_id);
+
+drop policy if exists "order_status_events_admin_all" on public.order_status_events;
+create policy "order_status_events_admin_all"
+on public.order_status_events for all
+to authenticated
+using ((select public.current_user_is_admin()))
+with check ((select public.current_user_is_admin()));
+
+drop policy if exists "wishlists_owner_all" on public.wishlists;
+create policy "wishlists_owner_all"
+on public.wishlists for all
+to authenticated
+using (auth.uid() = user_id)
+with check (auth.uid() = user_id);
+
+drop policy if exists "wishlists_admin_all" on public.wishlists;
+create policy "wishlists_admin_all"
+on public.wishlists for all
+to authenticated
+using ((select public.current_user_is_admin()))
+with check ((select public.current_user_is_admin()));
+
insert into public.categories (name, slug, description, accent_color, sort_order, is_active)
values
('Bedroom', 'bedroom', 'Layered essentials for restorative spaces.', '#d6b48b', 1, true),
@@ -1244,3 +1353,88 @@ join public.product_option_values pov
on pov.option_id = po.id
and pov.label = seed.label
on conflict (variant_id, option_value_id) do nothing;
+
+-- Admin-only fulfillment transitions. Both RPCs check current_user_is_admin()
+-- inside the function so the SDK call can be made under the user's session
+-- token without bypassing RLS.
+create or replace function public.mark_order_shipped(
+ p_order_id uuid,
+ p_tracking_number text default null
+)
+returns public.orders
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ v_order public.orders%rowtype;
+begin
+ if not public.current_user_is_admin() then
+ raise exception 'Only project admins can mark orders as shipped.';
+ end if;
+
+ update public.orders
+ set fulfillment_status = 'shipped',
+ tracking_number = coalesce(p_tracking_number, tracking_number),
+ shipped_at = coalesce(shipped_at, now()),
+ updated_at = now()
+ where id = p_order_id
+ and payment_status = 'paid'
+ and fulfillment_status in ('processing', 'unfulfilled')
+ returning * into v_order;
+
+ if v_order.id is null then
+ raise exception 'Order not found or not eligible to ship.';
+ end if;
+
+ insert into public.order_status_events (order_id, user_id, event_type, message)
+ values (
+ v_order.id,
+ v_order.user_id,
+ 'fulfillment_shipped',
+ case
+ when p_tracking_number is not null then 'Shipped, tracking ' || p_tracking_number
+ else 'Order shipped'
+ end
+ );
+
+ return v_order;
+end;
+$$;
+
+create or replace function public.mark_order_delivered(p_order_id uuid)
+returns public.orders
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ v_order public.orders%rowtype;
+begin
+ if not public.current_user_is_admin() then
+ raise exception 'Only project admins can mark orders as delivered.';
+ end if;
+
+ update public.orders
+ set fulfillment_status = 'delivered',
+ delivered_at = coalesce(delivered_at, now()),
+ updated_at = now()
+ where id = p_order_id
+ and fulfillment_status = 'shipped'
+ returning * into v_order;
+
+ if v_order.id is null then
+ raise exception 'Order not found or not yet shipped.';
+ end if;
+
+ insert into public.order_status_events (order_id, user_id, event_type, message)
+ values (
+ v_order.id,
+ v_order.user_id,
+ 'fulfillment_delivered',
+ 'Order delivered'
+ );
+
+ return v_order;
+end;
+$$;
diff --git a/e-commerce/package-lock.json b/e-commerce/package-lock.json
index 67cad04..f10eb98 100644
--- a/e-commerce/package-lock.json
+++ b/e-commerce/package-lock.json
@@ -8,7 +8,7 @@
"name": "insforge-ecommerce",
"version": "0.1.0",
"dependencies": {
- "@insforge/sdk": "latest",
+ "@insforge/sdk": "^1.3.1",
"@radix-ui/react-slot": "^1.2.4",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
@@ -521,12 +521,12 @@
}
},
"node_modules/@insforge/sdk": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/@insforge/sdk/-/sdk-1.2.2.tgz",
- "integrity": "sha512-HoEeFzXj2OTFiN273DqpQvvruq6YsaftGOZ01NOIC/gFHnNQRLC3AiEcNlDWUSYqUDnxy5l3m23p5eFm/WaGTg==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@insforge/sdk/-/sdk-1.3.1.tgz",
+ "integrity": "sha512-hLHd/oiiMhmwq2OU7C363vVYfCjcI2VSaU+FT7x0rYIuCFMC9v6QyNrcaToGTzZU1xgC9rjXfBCo2k2SGp5XpQ==",
"license": "Apache-2.0",
"dependencies": {
- "@insforge/shared-schemas": "^1.1.46",
+ "@insforge/shared-schemas": "^1.1.55",
"@supabase/postgrest-js": "^1.21.3",
"socket.io-client": "^4.8.1"
},
@@ -535,9 +535,9 @@
}
},
"node_modules/@insforge/shared-schemas": {
- "version": "1.1.47",
- "resolved": "https://registry.npmjs.org/@insforge/shared-schemas/-/shared-schemas-1.1.47.tgz",
- "integrity": "sha512-lwx7ymV41l7QzqIWkZ81SnZKvudqqy0FchAiBOHp5dea37g/HCMcPxfHV/B89xY4GKLkWjF1P2Nwh58PKv7OHA==",
+ "version": "1.1.55",
+ "resolved": "https://registry.npmjs.org/@insforge/shared-schemas/-/shared-schemas-1.1.55.tgz",
+ "integrity": "sha512-xor4lpP1dRiAASuQ99hl7R8pi/FAiCmN7iLLlNT5nEUvDUDbI6Ja4hJt/ElkR1jxliiMlx1u3gMMCkrWAyvJVw==",
"license": "Apache-2.0",
"dependencies": {
"zod": "^3.23.8"
@@ -1083,7 +1083,6 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1721,7 +1720,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1731,7 +1729,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
diff --git a/e-commerce/package.json b/e-commerce/package.json
index f6294bb..4794fb6 100644
--- a/e-commerce/package.json
+++ b/e-commerce/package.json
@@ -9,7 +9,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@insforge/sdk": "latest",
+ "@insforge/sdk": "^1.3.1",
"@radix-ui/react-slot": "^1.2.4",
"@vercel/analytics": "^2.0.1",
"class-variance-authority": "^0.7.1",
diff --git a/registry.json b/registry.json
index 7d6b249..ba5ef6c 100644
--- a/registry.json
+++ b/registry.json
@@ -2,13 +2,13 @@
{
"slug": "e-commerce",
"name": "Storefront Starter",
- "description": "Launch an online store in an afternoon. Product catalog, cart, customer accounts, and an admin-side order dashboard come wired up.",
+ "description": "Launch an online store in an afternoon. Product catalog, cart, wishlist, real Stripe Checkout with Apple Pay and Google Pay, a live order status timeline, and an admin fulfillment flow that marks orders shipped and delivered.",
"category": "e-commerce",
"framework": "nextjs",
- "features": ["Order Dashboard", "shadcn/ui", "Auth"],
- "tags": ["e-commerce", "storefront"],
+ "features": ["Stripe Checkout", "Order Timeline", "Wishlist", "Admin Fulfillment", "shadcn/ui", "Auth"],
+ "tags": ["e-commerce", "storefront", "stripe", "payments", "wishlist"],
"cover": "assets/covers/e-commerce.png",
- "demo_url": "https://demoecommerce.insforge.site/",
+ "demo_url": "https://ecommerce-stripe.insforge.site/",
"author": "InsForge",
"added_at": "2026-05-20"
},