diff --git a/e-commerce/README.md b/e-commerce/README.md index bfa13aa..d0030d4 100644 --- a/e-commerce/README.md +++ b/e-commerce/README.md @@ -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 @@ -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: diff --git a/e-commerce/app/account/orders/[id]/page.tsx b/e-commerce/app/account/orders/[id]/page.tsx index d7ee901..b0d8e01 100644 --- a/e-commerce/app/account/orders/[id]/page.tsx +++ b/e-commerce/app/account/orders/[id]/page.tsx @@ -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'; @@ -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(); @@ -65,6 +66,19 @@ export default async function OrderDetailPage({ +
+

Order status

+ +
+ + {isAdmin ? ( + + ) : null} +
diff --git a/e-commerce/app/account/wishlist/page.tsx b/e-commerce/app/account/wishlist/page.tsx new file mode 100644 index 0000000..ae534d3 --- /dev/null +++ b/e-commerce/app/account/wishlist/page.tsx @@ -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 ( +
+ +
+ + +
+ {items.length === 0 ? ( +
+

+ Browse the catalog and tap the heart on anything you want to come back to. +

+ +
+ ) : ( +
    + {items.map((item) => ( +
  • +
    + +
    + + {item.product?.image_url ? ( +
    + {item.product.image_alt +
    + ) : null} +
    +

    {item.product?.name}

    + {item.product?.short_description ? ( +

    + {item.product.short_description} +

    + ) : null} +
    + +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/e-commerce/app/checkout/success/page.tsx b/e-commerce/app/checkout/success/page.tsx new file mode 100644 index 0000000..2eadae9 --- /dev/null +++ b/e-commerce/app/checkout/success/page.tsx @@ -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(null); + const redirectTimerRef = useRef | null>(null); + const pollTimerRef = useRef | 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 ( +
+ {status === 'polling' ? ( + <> + +

Confirming your payment

+

+ Stripe is letting us know your payment went through. This usually takes a few seconds. +

+ + ) : null} + {status === 'paid' ? ( + <> +

Payment confirmed

+

Redirecting to your order details.

+ + ) : null} + {status === 'timeout' ? ( + <> +

Still processing

+

+ 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. +

+ + + ) : null} + {status === 'error' ? ( + <> +

Something went wrong

+

{errorMsg}

+ + + ) : null} +
+ ); +} + +export default function CheckoutSuccessPage() { + return ( + + +

Loading...

+ + } + > + +
+ ); +} diff --git a/e-commerce/app/page.tsx b/e-commerce/app/page.tsx index 7e16210..3cd7876 100644 --- a/e-commerce/app/page.tsx +++ b/e-commerce/app/page.tsx @@ -5,16 +5,29 @@ 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 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, featuredProducts, latestProducts, wishlistIds] = await Promise.all([ + authPromise, getCategories(), getFeaturedProducts(), getProducts(), + wishlistPromise, ]); + const showWishlist = authState.viewer.isAuthenticated && !!authState.viewer.id; return (
@@ -92,7 +105,12 @@ export default async function HomePage() {
{featuredProducts.map((product) => ( - + ))}
@@ -126,7 +144,12 @@ export default async function HomePage() {
{latestProducts.slice(0, 3).map((product) => ( - + ))}
diff --git a/e-commerce/app/products/[slug]/page.tsx b/e-commerce/app/products/[slug]/page.tsx index 94ba9a6..ee84a37 100644 --- a/e-commerce/app/products/[slug]/page.tsx +++ b/e-commerce/app/products/[slug]/page.tsx @@ -6,7 +6,9 @@ import { ProductCard } from '@/components/product-card'; import { ProductConfigurator } from '@/components/product-configurator'; import { SiteFooter } from '@/components/site-footer'; import { SiteHeader } from '@/components/site-header'; -import { getProductBySlug, getProducts } from '@/lib/store'; +import { WishlistButton } from '@/components/wishlist-button'; +import { getCurrentAuthState } from '@/lib/auth-state'; +import { getProductBySlug, getProducts, getWishlistProductIds } from '@/lib/store'; export const dynamic = 'force-dynamic'; @@ -16,13 +18,29 @@ export default async function ProductDetailPage({ params: Promise<{ slug: string }>; }) { const { slug } = await params; - const product = await getProductBySlug(slug); + const [product, authState] = await Promise.all([ + getProductBySlug(slug), + getCurrentAuthState(), + ]); if (!product) { notFound(); } - const relatedProducts = (await getProducts({ category: product.category?.slug ?? undefined })) + const viewerId = authState.viewer.isAuthenticated ? authState.viewer.id : null; + const wishlistPromise = viewerId && authState.accessToken + ? getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }).catch( + () => new Set(), + ) + : Promise.resolve(new Set()); + + 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 ? ( +
+ + +
+ ) : null} + + {canDeliver ? ( +
+

+ Shipped on {' '} + {currentTracking ? {currentTracking} : 'no tracking number'} +

+ +
+ ) : 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[] }) { ); 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 ( +
  1. + + {isDone ? ( + + ) : isCurrent ? ( + + ) : ( + + )} + +
    +

    + {step.label} +

    + {event ? ( +

    + {format(new Date(event.created_at), 'MMM d, yyyy ยท h:mm a')} + {event.message ? `, ${event.message}` : ''} +

    + ) : null} +
    +
  2. + ); + })} +
+ ); +} 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" },