Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
95760cb
feat(e-commerce): schema for stripe checkout, wishlist, order timeline
CarmenDou Jun 2, 2026
f3bf3f1
fix(e-commerce): non-negative discount check, align policy style, doc…
CarmenDou Jun 2, 2026
8807b7f
feat(e-commerce): replace mock checkout with Stripe Checkout + promo …
CarmenDou Jun 2, 2026
aa4ef01
fix(e-commerce): error handling on price lookup, use NEXT_PUBLIC_APP_URL
CarmenDou Jun 2, 2026
b8e0437
feat(e-commerce): order timeline + checkout success polling
CarmenDou Jun 2, 2026
5b127fa
fix(e-commerce): clear timers, parallel order fetch, conditional reva…
CarmenDou Jun 2, 2026
eedc264
feat(e-commerce): wishlist with optimistic toggle and account page
CarmenDou Jun 3, 2026
1d9ad0c
fix(e-commerce): wider duplicate-key detection, parallelize wishlist …
CarmenDou Jun 3, 2026
54fd824
docs(e-commerce): stripe payments config, promo code caveat, order li…
CarmenDou Jun 3, 2026
9ef000b
docs(e-commerce): align timeline labels and clarify promo code state
CarmenDou Jun 3, 2026
3f97ea7
fix(e-commerce): hoist new RLS to existing cluster and use current_us…
CarmenDou Jun 3, 2026
03bfb17
fix(e-commerce): move click guards inside WishlistButton so ProductCa…
CarmenDou Jun 4, 2026
9250572
fix(e-commerce): fulfill orders via payments.payment_history trigger,…
CarmenDou Jun 4, 2026
b40b38f
feat(e-commerce): admin mark-shipped and mark-delivered RPCs with det…
CarmenDou Jun 5, 2026
a480652
feat(marketplace): refresh e-commerce listing for stripe, timeline, w…
CarmenDou Jun 5, 2026
871bdfb
fix(e-commerce): address pr review (parallel fetches, wishlist resili…
CarmenDou Jun 5, 2026
539206e
fix(e-commerce): use greatest(0, ...) for trigger inventory decrement…
CarmenDou Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions e-commerce/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ This starter includes a public storefront, seeded catalog data, customer authent
- [InsForge](https://insforge.dev) authentication, database, storage, and Row Level Security
- [Vercel Analytics](https://vercel.com/docs/analytics) for page-level traffic insights
- Built with [Next.js](https://nextjs.org), React 19, and [Tailwind CSS](https://tailwindcss.com)
- Real Stripe Checkout with Apple Pay, Google Pay, and Link via InsForge Payments
- Promotion codes redeemable at Stripe Checkout (configured in your Stripe dashboard, see Stripe setup section)
- Order status timeline: placed, payment confirmed, preparing, shipped, delivered
- Wishlist with optimistic heart toggle and a dedicated `/account/wishlist` page

## Demo

Expand Down Expand Up @@ -95,6 +99,58 @@ Use the local setup below if you want to inspect the repo, edit environment vari

8. Open [http://localhost:3000](http://localhost:3000)

## Configure Stripe payments

This template uses InsForge's managed Stripe integration. The Stripe secret key is stored on the InsForge backend, never in your frontend.

1. Add your Stripe test key to InsForge:

```bash
npx @insforge/cli payments config set test sk_test_xxx
```

2. Create one Stripe product + price per row in `public.products` (and per row in `public.product_variants` if you sell variants). The CLI mirrors them into `payments.products` and `payments.prices`:

```bash
npx @insforge/cli payments products create \
--environment test \
--name "Linen sofa" \
--idempotency-key "product:linen-sofa"

npx @insforge/cli payments prices create \
--environment test \
--product prod_xxx \
--currency usd \
--amount 129900
```

3. Copy the resulting `price_xxx` and write it onto the matching `public.products` row (or `public.product_variants` row for variant level pricing):

```sql
update public.products
set stripe_price_id = 'price_xxx'
where slug = 'linen-sofa';
```

4. When you are ready for production, repeat with `payments config set live sk_live_xxx` and seed live prices. The current implementation passes `'test'` as the environment to `payments.createCheckoutSession` (see `lib/store.ts`). Change that string, or wire it from an env variable, when you flip to live.

### Promotion codes

Promotion codes are not enabled by the template today because the InsForge SDK does not yet expose the `allowPromotionCodes` flag on the create-checkout-session call. Once the SDK adds the flag, this template will pass it through automatically. Until then:

1. In your Stripe dashboard, create a Coupon, then create a Promotion Code from that coupon (e.g. `SUMMER10` for 10 percent off).
2. Configure your Stripe Checkout settings to allow promotion codes by default at the account level, or pass the discount via the `discounts` parameter once the SDK supports it. The promo code input box on the Stripe Checkout page is controlled by Stripe, not by this template.
3. The `orders.discount_code` and `orders.discount_cents` columns are wired up but will not be populated until the SDK surfaces those fields on the payments projection table.

## Order lifecycle

1. The `place_order` PL/pgSQL function creates a `pending` order and records an `order_placed` event in `order_status_events`. It does not deduct inventory and does not convert the cart yet.
2. The `placeOrderAction` server action creates a Stripe Checkout Session via `insforge.payments.createCheckoutSession`.
3. The user is redirected to Stripe to enter a card or apply a promotion code.
4. Stripe redirects back to `/checkout/success?order_id=...&session_id=...`.
5. The success page polls `finalizeOrderAction`, which reads `payments.checkout_sessions.payment_status`. When `paid`, it calls the `finalize_order` RPC, which marks the order as paid + confirmed + processing, decrements inventory, converts the cart, and appends `payment_succeeded` + `fulfillment_processing` events.
6. The order detail page renders the timeline as a stepper. Subsequent fulfillment events (`fulfillment_shipped`, `fulfillment_delivered`) can be inserted by your fulfillment workflow to advance the stepper.

## Deploy to Vercel

After cloning the repo and running the starter locally, you can deploy it on Vercel:
Expand Down
28 changes: 21 additions & 7 deletions e-commerce/app/account/orders/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { ChevronLeft } from 'lucide-react';
import { AdminOrderActions } from '@/components/admin-order-actions';
import { OrderTimeline } from '@/components/order-timeline';
import { SiteHeader } from '@/components/site-header';
import { requireAuthenticatedSession } from '@/lib/auth-session';
import { getOrderById } from '@/lib/store';
import { getOrderById, getOrderTimeline, isCurrentUserAdmin } from '@/lib/store';
import { formatCurrency, formatShortDate } from '@/lib/utils';

export const dynamic = 'force-dynamic';
Expand All @@ -26,12 +28,11 @@ export default async function OrderDetailPage({
const { id } = await params;
const { viewer, accessToken } = await requireAuthenticatedSession();

const order = await getOrderById({
accessToken,
userId: viewer.id,
isAdmin: false,
id,
});
const [order, timeline, isAdmin] = await Promise.all([
getOrderById({ accessToken, userId: viewer.id, isAdmin: false, id }),
getOrderTimeline({ accessToken, orderId: id }),
isCurrentUserAdmin(accessToken),
]);

if (!order) {
notFound();
Expand Down Expand Up @@ -65,6 +66,19 @@ export default async function OrderDetailPage({
</div>
</div>

<section className="glass-panel space-y-4 p-6">
<h2 className="font-display text-4xl">Order status</h2>
<OrderTimeline events={timeline} />
</section>

{isAdmin ? (
<AdminOrderActions
orderId={order.id}
fulfillmentStatus={order.fulfillment_status}
currentTracking={order.tracking_number ?? null}
/>
) : null}

<section className="grid gap-8 xl:grid-cols-[minmax(0,1.2fr)_420px]">
<div className="space-y-5">
<section className="glass-panel overflow-hidden">
Expand Down
76 changes: 76 additions & 0 deletions e-commerce/app/account/wishlist/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Image from 'next/image';
import Link from 'next/link';
import { AccountHeader } from '@/components/account-header';
import { SiteHeader } from '@/components/site-header';
import { WishlistButton } from '@/components/wishlist-button';
import { requireAuthenticatedSession } from '@/lib/auth-session';
import { getWishlistWithProducts } from '@/lib/store';
import { Button } from '@/components/ui/button';

export const dynamic = 'force-dynamic';

export default async function WishlistPage() {
const { viewer, accessToken } = await requireAuthenticatedSession();
const items = await getWishlistWithProducts({ accessToken, userId: viewer.id });

return (
<div className="min-h-screen">
<SiteHeader />
<main className="page-shell space-y-8 py-10">
<AccountHeader
activeTab="wishlist"
description="Products you have saved for later. Tap the heart to remove an item from your list."
title="Wishlist."
/>

<section>
{items.length === 0 ? (
<div className="py-16 text-center">
<p className="text-sm text-muted-foreground mb-6">
Browse the catalog and tap the heart on anything you want to come back to.
</p>
<Button asChild>
<Link href="/products">Browse products</Link>
</Button>
</div>
) : (
<ul className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => (
<li key={item.id} className="glass-panel overflow-hidden">
<Link href={`/products/${item.product?.slug ?? ''}`} className="block">
{item.product?.image_url ? (
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
<div className="relative aspect-[4/5] overflow-hidden rounded-t-[24px] bg-muted/60">
<Image
src={item.product.image_url}
alt={item.product.image_alt || item.product.name}
fill
sizes="(min-width: 1280px) 25vw, (min-width: 768px) 50vw, 100vw"
className="object-cover"
/>
<div className="absolute right-3 top-3">
<WishlistButton
productId={item.product_id}
initialInWishlist
size="sm"
/>
</div>
</div>
) : null}
<div className="p-5">
<p className="font-display text-2xl leading-none">{item.product?.name}</p>
{item.product?.short_description ? (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">
{item.product.short_description}
</p>
) : null}
</div>
</Link>
</li>
))}
</ul>
)}
</section>
</main>
</div>
);
}
111 changes: 111 additions & 0 deletions e-commerce/app/checkout/success/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'use client';

import { Suspense, useEffect, useRef, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { pollOrderPaymentAction } from '@/lib/store-actions';
import { Button } from '@/components/ui/button';

const POLL_INTERVAL_MS = 1500;
const POLL_TIMEOUT_MS = 30_000;

function CheckoutSuccessContent() {
const router = useRouter();
const searchParams = useSearchParams();
const orderId = searchParams.get('order_id');

const [status, setStatus] = useState<'polling' | 'paid' | 'timeout' | 'error'>('polling');
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const redirectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
if (!orderId) {
setStatus('error');
setErrorMsg('Missing order identifier.');
return;
}

let cancelled = false;
const start = Date.now();

async function poll() {
try {
const result = await pollOrderPaymentAction({ orderId: orderId! });
if (cancelled) return;
if (result.paid) {
setStatus('paid');
redirectTimerRef.current = setTimeout(() => router.replace(`/account/orders/${orderId}`), 600);
return;
}
if (Date.now() - start > POLL_TIMEOUT_MS) {
setStatus('timeout');
return;
}
pollTimerRef.current = setTimeout(poll, POLL_INTERVAL_MS);
} catch (err) {
if (cancelled) return;
setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Unable to confirm payment.');
}
}

poll();
return () => {
cancelled = true;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
if (redirectTimerRef.current) clearTimeout(redirectTimerRef.current);
};
}, [orderId, router]);

return (
<main className="mx-auto flex min-h-[60vh] max-w-md flex-col items-center justify-center gap-4 px-6 text-center">
{status === 'polling' ? (
<>
<Loader2 className="size-8 animate-spin" />
<h1 className="font-display text-2xl">Confirming your payment</h1>
<p className="text-sm text-muted-foreground">
Stripe is letting us know your payment went through. This usually takes a few seconds.
</p>
</>
) : null}
{status === 'paid' ? (
<>
<h1 className="font-display text-2xl">Payment confirmed</h1>
<p className="text-sm text-muted-foreground">Redirecting to your order details.</p>
</>
) : null}
{status === 'timeout' ? (
<>
<h1 className="font-display text-2xl">Still processing</h1>
<p className="text-sm text-muted-foreground">
Your payment is taking longer than usual. We saved your order. You can refresh this page, or check your account to see the latest status.
</p>
<Button onClick={() => router.replace(`/account/orders/${orderId}`)}>View order</Button>
</>
) : null}
{status === 'error' ? (
<>
<h1 className="font-display text-2xl">Something went wrong</h1>
<p className="text-sm text-destructive">{errorMsg}</p>
<Button onClick={() => router.replace('/cart')}>Back to cart</Button>
</>
) : null}
</main>
);
}

export default function CheckoutSuccessPage() {
return (
<Suspense
fallback={
<main className="mx-auto flex min-h-[60vh] max-w-md flex-col items-center justify-center gap-4 px-6 text-center">
<Loader2 className="size-8 animate-spin" />
<p className="text-sm text-muted-foreground">Loading...</p>
</main>
}
>
<CheckoutSuccessContent />
</Suspense>
);
}
27 changes: 23 additions & 4 deletions e-commerce/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,25 @@ import { ProductCard } from '@/components/product-card';
import { SiteFooter } from '@/components/site-footer';
import { SiteHeader } from '@/components/site-header';
import { Button } from '@/components/ui/button';
import { getCategories, getFeaturedProducts, getProducts } from '@/lib/store';
import { getCurrentAuthState } from '@/lib/auth-state';
import { getCategories, getFeaturedProducts, getProducts, getWishlistProductIds } from '@/lib/store';

export const dynamic = 'force-dynamic';

export default async function HomePage() {
const [categories, featuredProducts, latestProducts] = await Promise.all([
const authState = await getCurrentAuthState();
const viewerId = authState.viewer.isAuthenticated ? authState.viewer.id : null;
const wishlistPromise = viewerId && authState.accessToken
? getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId })
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
: Promise.resolve(new Set<string>());

const [categories, featuredProducts, latestProducts, wishlistIds] = await Promise.all([
getCategories(),
getFeaturedProducts(),
getProducts(),
wishlistPromise,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
]);
const showWishlist = !!viewerId;

return (
<div className="min-h-screen">
Expand Down Expand Up @@ -92,7 +101,12 @@ export default async function HomePage() {

<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
{featuredProducts.map((product) => (
<ProductCard key={product.id} product={product} />
<ProductCard
key={product.id}
product={product}
showWishlist={showWishlist}
inWishlist={wishlistIds.has(product.id)}
/>
))}
</div>
</section>
Expand Down Expand Up @@ -126,7 +140,12 @@ export default async function HomePage() {
</div>
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{latestProducts.slice(0, 3).map((product) => (
<ProductCard key={product.id} product={product} />
<ProductCard
key={product.id}
product={product}
showWishlist={showWishlist}
inWishlist={wishlistIds.has(product.id)}
/>
))}
</div>
</section>
Expand Down
Loading
Loading