From 95760cb832f6ad9c1cad18001f46d9615c4b22ac Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 2 Jun 2026 13:16:48 -0700 Subject: [PATCH 01/17] feat(e-commerce): schema for stripe checkout, wishlist, order timeline --- e-commerce/migrations/db_init.sql | 262 +++++++++++++++++++----------- 1 file changed, 166 insertions(+), 96 deletions(-) diff --git a/e-commerce/migrations/db_init.sql b/e-commerce/migrations/db_init.sql index 3fe6093..f7241ea 100644 --- a/e-commerce/migrations/db_init.sql +++ b/e-commerce/migrations/db_init.sql @@ -218,6 +218,55 @@ 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); + +alter table public.order_status_events enable row level security; + +create policy if not exists order_status_events_owner_select on public.order_status_events + for select using (auth.uid() = user_id); + +create policy if not exists order_status_events_admin_all on public.order_status_events + for all using (public.is_project_admin()); + +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.wishlists enable row level security; + +create policy if not exists wishlists_owner_all on public.wishlists + for all using (auth.uid() = user_id) with check (auth.uid() = user_id); + +create policy if not exists wishlists_admin_all on public.wishlists + for all using (public.is_project_admin()); + alter table public.cart_items add column if not exists variant_id uuid references public.product_variants(id) on delete set null; @@ -226,6 +275,18 @@ 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; + do $$ begin execute 'drop function if exists public.sync_current_user_profile(text, text, text)'; @@ -270,7 +331,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 +394,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,122 +426,134 @@ 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; 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; +$$; + +create or replace function public.finalize_order( + p_order_id uuid, + p_stripe_session_id text, + p_payment_intent_id text default null, + p_discount_code text default null, + p_discount_cents integer default 0 +) +returns public.orders +language plpgsql +security definer +set search_path = public +as $$ +declare + v_user_id uuid := auth.uid(); + v_order public.orders%rowtype; + v_cart_id uuid; +begin + if v_user_id is null then + raise exception 'You must be signed in to finalize an order.'; + end if; + + select * into v_order from public.orders + where id = p_order_id and user_id = v_user_id for update; + + if not found then + raise exception 'Order not found.'; + end if; + + if v_order.payment_status = 'paid' then + return v_order; + end if; + + update public.orders set + status = 'confirmed', + payment_status = 'paid', + fulfillment_status = 'processing', + stripe_checkout_session_id = p_stripe_session_id, + stripe_payment_intent_id = p_payment_intent_id, + discount_code = p_discount_code, + discount_cents = coalesce(p_discount_cents, 0), + updated_at = now() + where id = p_order_id + returning * into v_order; + 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 = p_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; - - update public.shopping_carts - set status = 'converted', + from public.order_items oi + where oi.order_id = p_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; + where id = v_cart_id; + end if; - return v_order_id; + insert into public.order_status_events (order_id, user_id, event_type, message) + values (p_order_id, v_user_id, 'payment_succeeded', 'Payment confirmed via Stripe'); + + insert into public.order_status_events (order_id, user_id, event_type, message) + values (p_order_id, v_user_id, 'fulfillment_processing', 'Order is being prepared'); + + return v_order; end; $$; From f3bf3f1e3af57ef526a3afc6dc7b96fe35e56142 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 2 Jun 2026 13:59:18 -0700 Subject: [PATCH 02/17] fix(e-commerce): non-negative discount check, align policy style, document finalize_order context --- e-commerce/migrations/db_init.sql | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/e-commerce/migrations/db_init.sql b/e-commerce/migrations/db_init.sql index f7241ea..7a5b249 100644 --- a/e-commerce/migrations/db_init.sql +++ b/e-commerce/migrations/db_init.sql @@ -243,10 +243,12 @@ create index if not exists order_status_events_order_idx on public.order_status_ alter table public.order_status_events enable row level security; -create policy if not exists order_status_events_owner_select on public.order_status_events +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 using (auth.uid() = user_id); -create policy if not exists order_status_events_admin_all on public.order_status_events +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 using (public.is_project_admin()); create table if not exists public.wishlists ( @@ -261,10 +263,12 @@ create index if not exists wishlists_user_idx on public.wishlists (user_id, crea alter table public.wishlists enable row level security; -create policy if not exists wishlists_owner_all on public.wishlists +drop policy if exists wishlists_owner_all on public.wishlists; +create policy wishlists_owner_all on public.wishlists for all using (auth.uid() = user_id) with check (auth.uid() = user_id); -create policy if not exists wishlists_admin_all on public.wishlists +drop policy if exists wishlists_admin_all on public.wishlists; +create policy wishlists_admin_all on public.wishlists for all using (public.is_project_admin()); alter table public.cart_items @@ -287,6 +291,18 @@ alter table public.orders add column if not exists discount_code text, add column if not exists discount_cents integer not null default 0; +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)'; @@ -476,6 +492,11 @@ begin end; $$; +-- finalize_order is called by the user's session after Stripe redirects them back +-- to the success page. It expects auth.uid() to be the buyer's user id and will +-- raise if called without an authenticated context (e.g. a Stripe webhook server- +-- to-server call). The success page reads payments.checkout_sessions.payment_status +-- to verify payment before invoking this RPC. create or replace function public.finalize_order( p_order_id uuid, p_stripe_session_id text, From 8807b7fd041aa40d6469845c31699b5e6b228f21 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 2 Jun 2026 14:45:14 -0700 Subject: [PATCH 03/17] feat(e-commerce): replace mock checkout with Stripe Checkout + promo codes --- e-commerce/components/checkout-form.tsx | 11 +-- e-commerce/lib/store-actions.ts | 18 +++- e-commerce/lib/store.ts | 113 ++++++++++++++++++++++++ e-commerce/lib/types.ts | 6 ++ e-commerce/package-lock.json | 19 ++-- e-commerce/package.json | 2 +- 6 files changed, 148 insertions(+), 21 deletions(-) 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/lib/store-actions.ts b/e-commerce/lib/store-actions.ts index b8c45ce..aebd4d9 100644 --- a/e-commerce/lib/store-actions.ts +++ b/e-commerce/lib/store-actions.ts @@ -8,6 +8,7 @@ import { import { addItemToCart, createAddress, + createCheckoutSessionForOrder, deleteSavedAddress, placeOrderForUser, removeCartItem, @@ -70,11 +71,24 @@ export async function placeOrderAction(payload: { note: payload.note, }); + const { headers } = await import('next/headers'); + const headersList = await headers(); + const host = headersList.get('host') ?? 'localhost:3000'; + const protocol = host.startsWith('localhost') ? 'http' : 'https'; + const origin = `${protocol}://${host}`; + + 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) { diff --git a/e-commerce/lib/store.ts b/e-commerce/lib/store.ts index 9cf614c..52b8e86 100644 --- a/e-commerce/lib/store.ts +++ b/e-commerce/lib/store.ts @@ -603,6 +603,119 @@ 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 } = await insforge.database + .from('products') + .select('id, stripe_price_id') + .in('id', productIds); + + const { data: variants } = variantIds.length + ? await insforge.database + .from('product_variants') + .select('id, stripe_price_id') + .in('id', variantIds) + : { data: [] }; + + 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 finalizeOrderForUser(args: { + accessToken: string; + orderId: string; + stripeSessionId: string; +}) { + const insforge = getInsforge(args.accessToken); + + const { data: paymentRow, error: payErr } = await insforge.database + .from('payments.checkout_sessions') + .select('payment_status, stripe_payment_intent_id') + .eq('stripe_checkout_session_id', args.stripeSessionId) + .single(); + + assertNoDatabaseError(payErr, 'Unable to verify payment.'); + + if (!paymentRow || paymentRow.payment_status !== 'paid') { + return { paid: false as const }; + } + + const { data: order, error } = await insforge.database.rpc('finalize_order', { + p_order_id: args.orderId, + p_stripe_session_id: args.stripeSessionId, + p_payment_intent_id: paymentRow.stripe_payment_intent_id ?? null, + p_discount_code: null, + p_discount_cents: 0, + }); + + assertNoDatabaseError(error, 'Unable to finalize order.'); + return { paid: true as const, order }; +} + export async function getOrders(args: { accessToken: string; userId: string; diff --git a/e-commerce/lib/types.ts b/e-commerce/lib/types.ts index 2c18693..04fa63f 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 { @@ -162,5 +164,9 @@ export interface Order { placed_at: string; created_at: string; updated_at: string; + 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/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", From aa4ef01ede9cde4d6945daa4bbc4e8cce40c677a Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 2 Jun 2026 15:23:41 -0700 Subject: [PATCH 04/17] fix(e-commerce): error handling on price lookup, use NEXT_PUBLIC_APP_URL --- e-commerce/lib/store-actions.ts | 8 +++----- e-commerce/lib/store.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/e-commerce/lib/store-actions.ts b/e-commerce/lib/store-actions.ts index aebd4d9..d605dce 100644 --- a/e-commerce/lib/store-actions.ts +++ b/e-commerce/lib/store-actions.ts @@ -71,11 +71,9 @@ export async function placeOrderAction(payload: { note: payload.note, }); - const { headers } = await import('next/headers'); - const headersList = await headers(); - const host = headersList.get('host') ?? 'localhost:3000'; - const protocol = host.startsWith('localhost') ? 'http' : 'https'; - const origin = `${protocol}://${host}`; + const origin = process.env.NODE_ENV === 'development' + ? 'http://localhost:3000' + : process.env.NEXT_PUBLIC_APP_URL!; const checkoutUrl = await createCheckoutSessionForOrder({ accessToken, diff --git a/e-commerce/lib/store.ts b/e-commerce/lib/store.ts index 52b8e86..17e17bf 100644 --- a/e-commerce/lib/store.ts +++ b/e-commerce/lib/store.ts @@ -633,17 +633,19 @@ export async function createCheckoutSessionForOrder(args: { 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 } = await insforge.database + 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 } = variantIds.length + const { data: variants, error: variantsError } = variantIds.length ? await insforge.database .from('product_variants') .select('id, stripe_price_id') .in('id', variantIds) - : { data: [] }; + : { data: [], error: null }; + assertNoDatabaseError(variantsError, 'Unable to load variant prices.'); const productPriceMap = new Map( (products ?? []).map((p) => [p.id, p.stripe_price_id ?? null]), @@ -713,7 +715,7 @@ export async function finalizeOrderForUser(args: { }); assertNoDatabaseError(error, 'Unable to finalize order.'); - return { paid: true as const, order }; + return { paid: true as const, order: order as Order }; } export async function getOrders(args: { From b8e043792cd36fe0720ceba5f52286135dba99c9 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 2 Jun 2026 16:00:50 -0700 Subject: [PATCH 05/17] feat(e-commerce): order timeline + checkout success polling --- e-commerce/app/account/orders/[id]/page.tsx | 10 +- e-commerce/app/checkout/success/page.tsx | 108 ++++++++++++++++++++ e-commerce/components/order-timeline.tsx | 53 ++++++++++ e-commerce/lib/store-actions.ts | 13 +++ e-commerce/lib/store.ts | 16 +++ e-commerce/lib/types.ts | 19 ++++ 6 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 e-commerce/app/checkout/success/page.tsx create mode 100644 e-commerce/components/order-timeline.tsx diff --git a/e-commerce/app/account/orders/[id]/page.tsx b/e-commerce/app/account/orders/[id]/page.tsx index d7ee901..555e832 100644 --- a/e-commerce/app/account/orders/[id]/page.tsx +++ b/e-commerce/app/account/orders/[id]/page.tsx @@ -2,9 +2,10 @@ import Image from 'next/image'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import { ChevronLeft } from 'lucide-react'; +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 } from '@/lib/store'; import { formatCurrency, formatShortDate } from '@/lib/utils'; export const dynamic = 'force-dynamic'; @@ -37,6 +38,8 @@ export default async function OrderDetailPage({ notFound(); } + const timeline = await getOrderTimeline({ accessToken, orderId: order.id }); + return (
@@ -65,6 +68,11 @@ export default async function OrderDetailPage({
+
+

Order status

+ +
+
diff --git a/e-commerce/app/checkout/success/page.tsx b/e-commerce/app/checkout/success/page.tsx new file mode 100644 index 0000000..6fe7e38 --- /dev/null +++ b/e-commerce/app/checkout/success/page.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { Suspense, useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; +import { finalizeOrderAction } 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 sessionId = searchParams.get('session_id'); + + const [status, setStatus] = useState<'polling' | 'paid' | 'timeout' | 'error'>('polling'); + const [errorMsg, setErrorMsg] = useState(null); + + useEffect(() => { + if (!orderId || !sessionId) { + setStatus('error'); + setErrorMsg('Missing order or session identifier.'); + return; + } + + let cancelled = false; + const start = Date.now(); + + async function poll() { + try { + const result = await finalizeOrderAction({ orderId: orderId!, stripeSessionId: sessionId! }); + if (cancelled) return; + if (result.paid) { + setStatus('paid'); + setTimeout(() => router.replace(`/account/orders/${orderId}`), 600); + return; + } + if (Date.now() - start > POLL_TIMEOUT_MS) { + setStatus('timeout'); + return; + } + setTimeout(poll, POLL_INTERVAL_MS); + } catch (err) { + if (cancelled) return; + setStatus('error'); + setErrorMsg(err instanceof Error ? err.message : 'Unable to finalize order.'); + } + } + + poll(); + return () => { + cancelled = true; + }; + }, [orderId, sessionId, 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/components/order-timeline.tsx b/e-commerce/components/order-timeline.tsx new file mode 100644 index 0000000..ba6714a --- /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/lib/store-actions.ts b/e-commerce/lib/store-actions.ts index d605dce..6f2910c 100644 --- a/e-commerce/lib/store-actions.ts +++ b/e-commerce/lib/store-actions.ts @@ -10,6 +10,7 @@ import { createAddress, createCheckoutSessionForOrder, deleteSavedAddress, + finalizeOrderForUser, placeOrderForUser, removeCartItem, setDefaultSavedAddress, @@ -131,3 +132,15 @@ export async function setDefaultAddressAction( revalidatePath('/account/profile'); revalidatePath('/checkout'); } + +export async function finalizeOrderAction(payload: { orderId: string; stripeSessionId: string }) { + const { accessToken } = await requireSession(); + const result = await finalizeOrderForUser({ + accessToken, + orderId: payload.orderId, + stripeSessionId: payload.stripeSessionId, + }); + revalidatePath('/account/orders'); + revalidatePath(`/account/orders/${payload.orderId}`); + return result; +} diff --git a/e-commerce/lib/store.ts b/e-commerce/lib/store.ts index 17e17bf..238f1a4 100644 --- a/e-commerce/lib/store.ts +++ b/e-commerce/lib/store.ts @@ -10,6 +10,7 @@ import type { CartItem, Category, Order, + OrderStatusEvent, Product, ProductOption, ProductOptionValue, @@ -758,3 +759,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 04fa63f..24db532 100644 --- a/e-commerce/lib/types.ts +++ b/e-commerce/lib/types.ts @@ -139,6 +139,25 @@ 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 Order { id: string; order_number: string; From 5b127fae2b1b2d2f3750bab11be33cf79e021a80 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 2 Jun 2026 16:25:04 -0700 Subject: [PATCH 06/17] fix(e-commerce): clear timers, parallel order fetch, conditional revalidate, aria-current --- e-commerce/app/account/orders/[id]/page.tsx | 12 ++++-------- e-commerce/app/checkout/success/page.tsx | 10 +++++++--- e-commerce/components/order-timeline.tsx | 2 +- e-commerce/lib/store-actions.ts | 6 ++++-- 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/e-commerce/app/account/orders/[id]/page.tsx b/e-commerce/app/account/orders/[id]/page.tsx index 555e832..e0dc38f 100644 --- a/e-commerce/app/account/orders/[id]/page.tsx +++ b/e-commerce/app/account/orders/[id]/page.tsx @@ -27,19 +27,15 @@ 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] = await Promise.all([ + getOrderById({ accessToken, userId: viewer.id, isAdmin: false, id }), + getOrderTimeline({ accessToken, orderId: id }), + ]); if (!order) { notFound(); } - const timeline = await getOrderTimeline({ accessToken, orderId: order.id }); - return (
diff --git a/e-commerce/app/checkout/success/page.tsx b/e-commerce/app/checkout/success/page.tsx index 6fe7e38..041fdc6 100644 --- a/e-commerce/app/checkout/success/page.tsx +++ b/e-commerce/app/checkout/success/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Suspense, useEffect, useState } from 'react'; +import { Suspense, useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Loader2 } from 'lucide-react'; import { finalizeOrderAction } from '@/lib/store-actions'; @@ -17,6 +17,8 @@ function CheckoutSuccessContent() { 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 || !sessionId) { @@ -34,14 +36,14 @@ function CheckoutSuccessContent() { if (cancelled) return; if (result.paid) { setStatus('paid'); - setTimeout(() => router.replace(`/account/orders/${orderId}`), 600); + redirectTimerRef.current = setTimeout(() => router.replace(`/account/orders/${orderId}`), 600); return; } if (Date.now() - start > POLL_TIMEOUT_MS) { setStatus('timeout'); return; } - setTimeout(poll, POLL_INTERVAL_MS); + pollTimerRef.current = setTimeout(poll, POLL_INTERVAL_MS); } catch (err) { if (cancelled) return; setStatus('error'); @@ -52,6 +54,8 @@ function CheckoutSuccessContent() { poll(); return () => { cancelled = true; + if (pollTimerRef.current) clearTimeout(pollTimerRef.current); + if (redirectTimerRef.current) clearTimeout(redirectTimerRef.current); }; }, [orderId, sessionId, router]); diff --git a/e-commerce/components/order-timeline.tsx b/e-commerce/components/order-timeline.tsx index ba6714a..564cc33 100644 --- a/e-commerce/components/order-timeline.tsx +++ b/e-commerce/components/order-timeline.tsx @@ -24,7 +24,7 @@ export function OrderTimeline({ events }: { events: OrderStatusEvent[] }) { const isCurrent = !isDone && index === firstUnreachedIndex; return ( -
  • +
  • {isDone ? ( diff --git a/e-commerce/lib/store-actions.ts b/e-commerce/lib/store-actions.ts index 6f2910c..ad156ed 100644 --- a/e-commerce/lib/store-actions.ts +++ b/e-commerce/lib/store-actions.ts @@ -140,7 +140,9 @@ export async function finalizeOrderAction(payload: { orderId: string; stripeSess orderId: payload.orderId, stripeSessionId: payload.stripeSessionId, }); - revalidatePath('/account/orders'); - revalidatePath(`/account/orders/${payload.orderId}`); + if (result.paid) { + revalidatePath('/account/orders'); + revalidatePath(`/account/orders/${payload.orderId}`); + } return result; } From eedc2649cfbc49b51af6b51fec2995ae6e2dfb22 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Tue, 2 Jun 2026 17:50:30 -0700 Subject: [PATCH 07/17] feat(e-commerce): wishlist with optimistic toggle and account page --- e-commerce/app/account/wishlist/page.tsx | 79 ++++++++++++++++++++++ e-commerce/app/page.tsx | 26 +++++-- e-commerce/app/products/[slug]/page.tsx | 33 +++++++-- e-commerce/app/products/page.tsx | 14 +++- e-commerce/components/account-dropdown.tsx | 11 ++- e-commerce/components/account-header.tsx | 3 +- e-commerce/components/product-card.tsx | 14 ++++ e-commerce/components/wishlist-button.tsx | 53 +++++++++++++++ e-commerce/lib/store.ts | 61 +++++++++++++++++ e-commerce/lib/types.ts | 8 +++ e-commerce/lib/wishlist-actions.ts | 29 ++++++++ 11 files changed, 317 insertions(+), 14 deletions(-) create mode 100644 e-commerce/app/account/wishlist/page.tsx create mode 100644 e-commerce/components/wishlist-button.tsx create mode 100644 e-commerce/lib/wishlist-actions.ts diff --git a/e-commerce/app/account/wishlist/page.tsx b/e-commerce/app/account/wishlist/page.tsx new file mode 100644 index 0000000..3fb9601 --- /dev/null +++ b/e-commerce/app/account/wishlist/page.tsx @@ -0,0 +1,79 @@ +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 +
      e.preventDefault()} + > + +
      +
      + ) : null} +
      +

      {item.product?.name}

      + {item.product?.short_description ? ( +

      + {item.product.short_description} +

      + ) : null} +
      + +
    • + ))} +
    + )} +
    +
    +
    + ); +} diff --git a/e-commerce/app/page.tsx b/e-commerce/app/page.tsx index 7e16210..5fe69cd 100644 --- a/e-commerce/app/page.tsx +++ b/e-commerce/app/page.tsx @@ -5,17 +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 [categories, featuredProducts, latestProducts, authState] = await Promise.all([ getCategories(), getFeaturedProducts(), getProducts(), + getCurrentAuthState(), ]); + const viewerId = authState.viewer.isAuthenticated ? authState.viewer.id : null; + const wishlistIds = viewerId && authState.accessToken + ? await getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }) + : new Set(); + const showWishlist = !!viewerId; + return (
    @@ -92,7 +100,12 @@ export default async function HomePage() {
    {featuredProducts.map((product) => ( - + ))}
  • @@ -126,7 +139,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..7f8a46d 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,12 +18,21 @@ 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 viewerId = authState.viewer.isAuthenticated ? authState.viewer.id : null; + const wishlistIds = viewerId && authState.accessToken + ? await getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }) + : new Set(); + const inWishlist = wishlistIds.has(product.id); + const relatedProducts = (await getProducts({ category: product.category?.slug ?? undefined })) .filter((item) => item.id !== product.id) .slice(0, 3); @@ -56,9 +67,14 @@ export default async function ProductDetailPage({
    -

    - {product.category?.name} -

    +
    +

    + {product.category?.name} +

    + {viewerId ? ( + + ) : null} +

    {product.name}

    {product.description}

    @@ -94,7 +110,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..f5625ed 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,17 @@ export default async function ProductsPage({ searchParams: Promise<{ category?: string; search?: string }>; }) { const params = await searchParams; - const [categories, products] = await Promise.all([ + const [categories, products, authState] = await Promise.all([ getCategories(), getProducts({ category: params.category, search: params.search }), + getCurrentAuthState(), ]); + + const viewerId = authState.viewer.isAuthenticated ? authState.viewer.id : null; + const wishlistIds = viewerId && authState.accessToken + ? await getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }) + : new Set(); + const showWishlist = !!viewerId; const activeCategory = params.category ?? null; function buildCatalogHref(category?: string) { @@ -83,6 +91,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 + @@ -26,6 +31,15 @@ export function ProductCard({ fetchPriority={imageFetchPriority} /> ) : null} + {showWishlist ? ( +
    e.preventDefault()}> + +
    + ) : null}
    diff --git a/e-commerce/components/wishlist-button.tsx b/e-commerce/components/wishlist-button.tsx new file mode 100644 index 0000000..393720d --- /dev/null +++ b/e-commerce/components/wishlist-button.tsx @@ -0,0 +1,53 @@ +'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 [, startTransition] = useTransition(); + + function handleClick() { + 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.ts b/e-commerce/lib/store.ts index 238f1a4..a9598d7 100644 --- a/e-commerce/lib/store.ts +++ b/e-commerce/lib/store.ts @@ -17,6 +17,7 @@ import type { ProductVariant, SavedAddress, ShoppingCart, + WishlistItem, } from '@/lib/types'; type InsforgeClient = ReturnType; @@ -474,6 +475,66 @@ 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. + if (error && !`${error.message ?? ''}`.includes('wishlists_user_product_unique')) { + 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 diff --git a/e-commerce/lib/types.ts b/e-commerce/lib/types.ts index 24db532..a7c202e 100644 --- a/e-commerce/lib/types.ts +++ b/e-commerce/lib/types.ts @@ -158,6 +158,14 @@ export interface OrderStatusEvent { 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; 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 }; +} From 1d9ad0c2cf7513512bb32675d913c88bc7effe33 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Wed, 3 Jun 2026 10:26:25 -0700 Subject: [PATCH 08/17] fix(e-commerce): wider duplicate-key detection, parallelize wishlist fetch, stop heart click propagation --- e-commerce/app/account/wishlist/page.tsx | 2 +- e-commerce/app/page.tsx | 15 ++++++++------- e-commerce/app/products/[slug]/page.tsx | 13 +++++++++---- e-commerce/app/products/page.tsx | 15 ++++++++------- e-commerce/components/product-card.tsx | 2 +- e-commerce/lib/store.ts | 10 +++++++++- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/e-commerce/app/account/wishlist/page.tsx b/e-commerce/app/account/wishlist/page.tsx index 3fb9601..857fc80 100644 --- a/e-commerce/app/account/wishlist/page.tsx +++ b/e-commerce/app/account/wishlist/page.tsx @@ -49,7 +49,7 @@ export default async function WishlistPage() { />
    e.preventDefault()} + onClick={(e) => { e.preventDefault(); e.stopPropagation(); }} > ()); + + const [categories, featuredProducts, latestProducts, wishlistIds] = await Promise.all([ getCategories(), getFeaturedProducts(), getProducts(), - getCurrentAuthState(), + wishlistPromise, ]); - - const viewerId = authState.viewer.isAuthenticated ? authState.viewer.id : null; - const wishlistIds = viewerId && authState.accessToken - ? await getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }) - : new Set(); const showWishlist = !!viewerId; return ( diff --git a/e-commerce/app/products/[slug]/page.tsx b/e-commerce/app/products/[slug]/page.tsx index 7f8a46d..0095400 100644 --- a/e-commerce/app/products/[slug]/page.tsx +++ b/e-commerce/app/products/[slug]/page.tsx @@ -28,12 +28,17 @@ export default async function ProductDetailPage({ } const viewerId = authState.viewer.isAuthenticated ? authState.viewer.id : null; - const wishlistIds = viewerId && authState.accessToken - ? await getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }) - : new Set(); + const wishlistPromise = viewerId && authState.accessToken + ? getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }) + : 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 = (await getProducts({ category: product.category?.slug ?? undefined })) + const relatedProducts = allRelated .filter((item) => item.id !== product.id) .slice(0, 3); diff --git a/e-commerce/app/products/page.tsx b/e-commerce/app/products/page.tsx index f5625ed..cc6602a 100644 --- a/e-commerce/app/products/page.tsx +++ b/e-commerce/app/products/page.tsx @@ -15,16 +15,17 @@ export default async function ProductsPage({ searchParams: Promise<{ category?: string; search?: string }>; }) { const params = await searchParams; - const [categories, products, authState] = 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 }) + : Promise.resolve(new Set()); + + const [categories, products, wishlistIds] = await Promise.all([ getCategories(), getProducts({ category: params.category, search: params.search }), - getCurrentAuthState(), + wishlistPromise, ]); - - const viewerId = authState.viewer.isAuthenticated ? authState.viewer.id : null; - const wishlistIds = viewerId && authState.accessToken - ? await getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }) - : new Set(); const showWishlist = !!viewerId; const activeCategory = params.category ?? null; diff --git a/e-commerce/components/product-card.tsx b/e-commerce/components/product-card.tsx index b745269..b1ae689 100644 --- a/e-commerce/components/product-card.tsx +++ b/e-commerce/components/product-card.tsx @@ -32,7 +32,7 @@ export function ProductCard({ /> ) : null} {showWishlist ? ( -
    e.preventDefault()}> +
    { e.preventDefault(); e.stopPropagation(); }}> Date: Wed, 3 Jun 2026 10:43:26 -0700 Subject: [PATCH 09/17] docs(e-commerce): stripe payments config, promo code caveat, order lifecycle --- e-commerce/README.md | 56 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/e-commerce/README.md b/e-commerce/README.md index bfa13aa..647df78 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, paid, 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 + +The template enables promotion codes at the Stripe Checkout level. To use them: + +1. In your Stripe dashboard, create a Coupon, then create a Promotion Code from that coupon (e.g. `SUMMER10` for 10 percent off). +2. **Note:** the InsForge SDK currently does not expose the `allowPromotionCodes` flag on the create-checkout-session call. Until it does, 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: From 9ef000b360fe36968ccbce14df67b78d3275c261 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Wed, 3 Jun 2026 11:10:39 -0700 Subject: [PATCH 10/17] docs(e-commerce): align timeline labels and clarify promo code state --- e-commerce/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e-commerce/README.md b/e-commerce/README.md index 647df78..d0030d4 100644 --- a/e-commerce/README.md +++ b/e-commerce/README.md @@ -18,7 +18,7 @@ This starter includes a public storefront, seeded catalog data, customer authent - 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, paid, preparing, shipped, delivered +- Order status timeline: placed, payment confirmed, preparing, shipped, delivered - Wishlist with optimistic heart toggle and a dedicated `/account/wishlist` page ## Demo @@ -136,10 +136,10 @@ This template uses InsForge's managed Stripe integration. The Stripe secret key ### Promotion codes -The template enables promotion codes at the Stripe Checkout level. To use them: +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. **Note:** the InsForge SDK currently does not expose the `allowPromotionCodes` flag on the create-checkout-session call. Until it does, 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. +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 From 3f97ea70f5e137b93a35631edecf5ba262127559 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Wed, 3 Jun 2026 15:20:41 -0700 Subject: [PATCH 11/17] fix(e-commerce): hoist new RLS to existing cluster and use current_user_is_admin --- e-commerce/migrations/db_init.sql | 49 ++++++++++++++++++------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/e-commerce/migrations/db_init.sql b/e-commerce/migrations/db_init.sql index 7a5b249..4548816 100644 --- a/e-commerce/migrations/db_init.sql +++ b/e-commerce/migrations/db_init.sql @@ -241,16 +241,6 @@ create table if not exists public.order_status_events ( create index if not exists order_status_events_order_idx on public.order_status_events (order_id, created_at asc); -alter table public.order_status_events enable row level security; - -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 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 using (public.is_project_admin()); - 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, @@ -261,16 +251,6 @@ create table if not exists public.wishlists ( create index if not exists wishlists_user_idx on public.wishlists (user_id, created_at desc); -alter table public.wishlists enable row level security; - -drop policy if exists wishlists_owner_all on public.wishlists; -create policy wishlists_owner_all on public.wishlists - for all 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 using (public.is_project_admin()); - alter table public.cart_items add column if not exists variant_id uuid references public.product_variants(id) on delete set null; @@ -643,6 +623,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" @@ -837,6 +819,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), From 03bfb173506aced1ea4b134c5f8b585078cb1426 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Wed, 3 Jun 2026 17:08:09 -0700 Subject: [PATCH 12/17] fix(e-commerce): move click guards inside WishlistButton so ProductCard stays a server component --- e-commerce/app/account/wishlist/page.tsx | 5 +---- e-commerce/components/product-card.tsx | 2 +- e-commerce/components/wishlist-button.tsx | 4 +++- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/e-commerce/app/account/wishlist/page.tsx b/e-commerce/app/account/wishlist/page.tsx index 857fc80..7927e49 100644 --- a/e-commerce/app/account/wishlist/page.tsx +++ b/e-commerce/app/account/wishlist/page.tsx @@ -47,10 +47,7 @@ export default async function WishlistPage() { sizes="(min-width: 1280px) 25vw, (min-width: 768px) 50vw, 100vw" className="object-cover" /> -
    { e.preventDefault(); e.stopPropagation(); }} - > +
    ) : null} {showWishlist ? ( -
    { e.preventDefault(); e.stopPropagation(); }}> +
    ) { + event.preventDefault(); + event.stopPropagation(); startTransition(async () => { const next = !optimistic; setOptimistic(next); From 925057278ab5b7801ffa5976ac39071bb3b35ad5 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 4 Jun 2026 09:58:35 -0700 Subject: [PATCH 13/17] fix(e-commerce): fulfill orders via payments.payment_history trigger, poll orders table on success page --- e-commerce/app/checkout/success/page.tsx | 13 ++-- e-commerce/lib/store-actions.ts | 10 +-- e-commerce/lib/store.ts | 37 ++++------ e-commerce/migrations/db_init.sql | 89 ++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 36 deletions(-) diff --git a/e-commerce/app/checkout/success/page.tsx b/e-commerce/app/checkout/success/page.tsx index 041fdc6..2eadae9 100644 --- a/e-commerce/app/checkout/success/page.tsx +++ b/e-commerce/app/checkout/success/page.tsx @@ -3,7 +3,7 @@ import { Suspense, useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { Loader2 } from 'lucide-react'; -import { finalizeOrderAction } from '@/lib/store-actions'; +import { pollOrderPaymentAction } from '@/lib/store-actions'; import { Button } from '@/components/ui/button'; const POLL_INTERVAL_MS = 1500; @@ -13,7 +13,6 @@ function CheckoutSuccessContent() { const router = useRouter(); const searchParams = useSearchParams(); const orderId = searchParams.get('order_id'); - const sessionId = searchParams.get('session_id'); const [status, setStatus] = useState<'polling' | 'paid' | 'timeout' | 'error'>('polling'); const [errorMsg, setErrorMsg] = useState(null); @@ -21,9 +20,9 @@ function CheckoutSuccessContent() { const pollTimerRef = useRef | null>(null); useEffect(() => { - if (!orderId || !sessionId) { + if (!orderId) { setStatus('error'); - setErrorMsg('Missing order or session identifier.'); + setErrorMsg('Missing order identifier.'); return; } @@ -32,7 +31,7 @@ function CheckoutSuccessContent() { async function poll() { try { - const result = await finalizeOrderAction({ orderId: orderId!, stripeSessionId: sessionId! }); + const result = await pollOrderPaymentAction({ orderId: orderId! }); if (cancelled) return; if (result.paid) { setStatus('paid'); @@ -47,7 +46,7 @@ function CheckoutSuccessContent() { } catch (err) { if (cancelled) return; setStatus('error'); - setErrorMsg(err instanceof Error ? err.message : 'Unable to finalize order.'); + setErrorMsg(err instanceof Error ? err.message : 'Unable to confirm payment.'); } } @@ -57,7 +56,7 @@ function CheckoutSuccessContent() { if (pollTimerRef.current) clearTimeout(pollTimerRef.current); if (redirectTimerRef.current) clearTimeout(redirectTimerRef.current); }; - }, [orderId, sessionId, router]); + }, [orderId, router]); return (
    diff --git a/e-commerce/lib/store-actions.ts b/e-commerce/lib/store-actions.ts index ad156ed..b023342 100644 --- a/e-commerce/lib/store-actions.ts +++ b/e-commerce/lib/store-actions.ts @@ -10,7 +10,7 @@ import { createAddress, createCheckoutSessionForOrder, deleteSavedAddress, - finalizeOrderForUser, + getOrderPaymentState, placeOrderForUser, removeCartItem, setDefaultSavedAddress, @@ -133,12 +133,12 @@ export async function setDefaultAddressAction( revalidatePath('/checkout'); } -export async function finalizeOrderAction(payload: { orderId: string; stripeSessionId: string }) { - const { accessToken } = await requireSession(); - const result = await finalizeOrderForUser({ +export async function pollOrderPaymentAction(payload: { orderId: string }) { + const { viewer, accessToken } = await requireSession(); + const result = await getOrderPaymentState({ accessToken, + userId: viewer.id, orderId: payload.orderId, - stripeSessionId: payload.stripeSessionId, }); if (result.paid) { revalidatePath('/account/orders'); diff --git a/e-commerce/lib/store.ts b/e-commerce/lib/store.ts index fb03e89..e1c7ffc 100644 --- a/e-commerce/lib/store.ts +++ b/e-commerce/lib/store.ts @@ -757,35 +757,24 @@ export async function createCheckoutSessionForOrder(args: { return session.checkoutSession.url; } -export async function finalizeOrderForUser(args: { +export async function getOrderPaymentState(args: { accessToken: string; + userId: string; orderId: string; - stripeSessionId: 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(); - const { data: paymentRow, error: payErr } = await insforge.database - .from('payments.checkout_sessions') - .select('payment_status, stripe_payment_intent_id') - .eq('stripe_checkout_session_id', args.stripeSessionId) - .single(); - - assertNoDatabaseError(payErr, 'Unable to verify payment.'); - - if (!paymentRow || paymentRow.payment_status !== 'paid') { - return { paid: false as const }; - } - - const { data: order, error } = await insforge.database.rpc('finalize_order', { - p_order_id: args.orderId, - p_stripe_session_id: args.stripeSessionId, - p_payment_intent_id: paymentRow.stripe_payment_intent_id ?? null, - p_discount_code: null, - p_discount_cents: 0, - }); - - assertNoDatabaseError(error, 'Unable to finalize order.'); - return { paid: true as const, order: order as Order }; + 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 getOrders(args: { diff --git a/e-commerce/migrations/db_init.sql b/e-commerce/migrations/db_init.sql index 4548816..2c090aa 100644 --- a/e-commerce/migrations/db_init.sql +++ b/e-commerce/migrations/db_init.sql @@ -558,6 +558,95 @@ begin end; $$; +-- handle_payment_succeeded runs from the InsForge webhook projection. +-- When payments.payment_history receives a succeeded one_time_payment row, +-- the trigger looks up the matching checkout_sessions.metadata.order_id and +-- promotes the app's public.orders row to paid + confirmed + processing, +-- decrements inventory, converts the cart, and writes timeline events. +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; + + select (cs.metadata->>'order_id')::uuid + into v_order_id + from payments.checkout_sessions cs + where cs.stripe_checkout_session_id = NEW.stripe_checkout_session_id + limit 1; + + if v_order_id is null then + return NEW; + end if; + + 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 - oi.quantity), + updated_at = now() + 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 - oi.quantity), + updated_at = now() + 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; + + 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 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 From b40b38fa2512a95f70da42c8e6d3d9893ec7586b Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 4 Jun 2026 17:43:29 -0700 Subject: [PATCH 14/17] feat(e-commerce): admin mark-shipped and mark-delivered RPCs with detail page actions --- e-commerce/app/account/orders/[id]/page.tsx | 14 ++- e-commerce/components/admin-order-actions.tsx | 87 ++++++++++++++++++ e-commerce/lib/store-actions.ts | 23 +++++ e-commerce/lib/store.ts | 33 +++++++ e-commerce/lib/types.ts | 3 + e-commerce/migrations/db_init.sql | 90 ++++++++++++++++++- 6 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 e-commerce/components/admin-order-actions.tsx diff --git a/e-commerce/app/account/orders/[id]/page.tsx b/e-commerce/app/account/orders/[id]/page.tsx index e0dc38f..b0d8e01 100644 --- a/e-commerce/app/account/orders/[id]/page.tsx +++ b/e-commerce/app/account/orders/[id]/page.tsx @@ -2,10 +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, getOrderTimeline } from '@/lib/store'; +import { getOrderById, getOrderTimeline, isCurrentUserAdmin } from '@/lib/store'; import { formatCurrency, formatShortDate } from '@/lib/utils'; export const dynamic = 'force-dynamic'; @@ -27,9 +28,10 @@ export default async function OrderDetailPage({ const { id } = await params; const { viewer, accessToken } = await requireAuthenticatedSession(); - const [order, timeline] = await Promise.all([ + const [order, timeline, isAdmin] = await Promise.all([ getOrderById({ accessToken, userId: viewer.id, isAdmin: false, id }), getOrderTimeline({ accessToken, orderId: id }), + isCurrentUserAdmin(accessToken), ]); if (!order) { @@ -69,6 +71,14 @@ export default async function OrderDetailPage({ + {isAdmin ? ( + + ) : null} +
    diff --git a/e-commerce/components/admin-order-actions.tsx b/e-commerce/components/admin-order-actions.tsx new file mode 100644 index 0000000..ce978f2 --- /dev/null +++ b/e-commerce/components/admin-order-actions.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { Loader2, PackageCheck, Truck } from 'lucide-react'; +import { toast } from 'sonner'; +import { markOrderDeliveredAction, markOrderShippedAction } from '@/lib/store-actions'; +import { Button } from '@/components/ui/button'; + +export function AdminOrderActions({ + orderId, + fulfillmentStatus, + currentTracking, +}: { + orderId: string; + fulfillmentStatus: string; + currentTracking: string | null; +}) { + const [tracking, setTracking] = useState(currentTracking ?? ''); + const [pending, startTransition] = useTransition(); + + const canShip = fulfillmentStatus === 'processing' || fulfillmentStatus === 'unfulfilled'; + const canDeliver = fulfillmentStatus === 'shipped'; + + if (!canShip && !canDeliver) return null; + + function handleShip() { + startTransition(async () => { + 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/lib/store-actions.ts b/e-commerce/lib/store-actions.ts index b023342..c403176 100644 --- a/e-commerce/lib/store-actions.ts +++ b/e-commerce/lib/store-actions.ts @@ -11,6 +11,8 @@ import { createCheckoutSessionForOrder, deleteSavedAddress, getOrderPaymentState, + markOrderDelivered, + markOrderShipped, placeOrderForUser, removeCartItem, setDefaultSavedAddress, @@ -146,3 +148,24 @@ export async function pollOrderPaymentAction(payload: { orderId: string }) { } 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 e1c7ffc..875eddb 100644 --- a/e-commerce/lib/store.ts +++ b/e-commerce/lib/store.ts @@ -777,6 +777,39 @@ export async function getOrderPaymentState(args: { : { 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; diff --git a/e-commerce/lib/types.ts b/e-commerce/lib/types.ts index a7c202e..cea84c6 100644 --- a/e-commerce/lib/types.ts +++ b/e-commerce/lib/types.ts @@ -191,6 +191,9 @@ 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; diff --git a/e-commerce/migrations/db_init.sql b/e-commerce/migrations/db_init.sql index 2c090aa..1ebbc62 100644 --- a/e-commerce/migrations/db_init.sql +++ b/e-commerce/migrations/db_init.sql @@ -269,7 +269,10 @@ 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 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 @@ -1433,3 +1436,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; +$$; From a48065268549f45eb11d3f1eccffb9f4f0357bd7 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Thu, 4 Jun 2026 17:53:44 -0700 Subject: [PATCH 15/17] feat(marketplace): refresh e-commerce listing for stripe, timeline, wishlist, admin fulfillment --- registry.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/registry.json b/registry.json index 7d6b249..a10b6cf 100644 --- a/registry.json +++ b/registry.json @@ -2,11 +2,11 @@ { "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/", "author": "InsForge", From 871bdfb1390603bb2124552c78b3cd1c64394f10 Mon Sep 17 00:00:00 2001 From: CarmenDou <15951653662@163.com> Date: Fri, 5 Jun 2026 10:43:00 -0700 Subject: [PATCH 16/17] fix(e-commerce): address pr review (parallel fetches, wishlist resilience, no-oversell trigger, demo url) --- e-commerce/app/account/wishlist/page.tsx | 16 +-- e-commerce/app/page.tsx | 18 +-- e-commerce/app/products/[slug]/page.tsx | 4 +- e-commerce/app/products/page.tsx | 18 +-- e-commerce/components/wishlist-button.tsx | 3 +- e-commerce/migrations/db_init.sql | 129 ++++------------------ registry.json | 2 +- 7 files changed, 60 insertions(+), 130 deletions(-) diff --git a/e-commerce/app/account/wishlist/page.tsx b/e-commerce/app/account/wishlist/page.tsx index 7927e49..ae534d3 100644 --- a/e-commerce/app/account/wishlist/page.tsx +++ b/e-commerce/app/account/wishlist/page.tsx @@ -36,7 +36,14 @@ export default async function WishlistPage() { ) : (
      {items.map((item) => ( -
    • +
    • +
      + +
      {item.product?.image_url ? (
      @@ -47,13 +54,6 @@ export default async function WishlistPage() { sizes="(min-width: 1280px) 25vw, (min-width: 768px) 50vw, 100vw" className="object-cover" /> -
      - -
      ) : null}
      diff --git a/e-commerce/app/page.tsx b/e-commerce/app/page.tsx index e6b5330..3cd7876 100644 --- a/e-commerce/app/page.tsx +++ b/e-commerce/app/page.tsx @@ -11,19 +11,23 @@ import { getCategories, getFeaturedProducts, getProducts, getWishlistProductIds export const dynamic = 'force-dynamic'; export default async function HomePage() { - const authState = await getCurrentAuthState(); - const viewerId = authState.viewer.isAuthenticated ? authState.viewer.id : null; - const wishlistPromise = viewerId && authState.accessToken - ? getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }) - : Promise.resolve(new Set()); + 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 [categories, featuredProducts, latestProducts, wishlistIds] = await Promise.all([ + const [authState, categories, featuredProducts, latestProducts, wishlistIds] = await Promise.all([ + authPromise, getCategories(), getFeaturedProducts(), getProducts(), wishlistPromise, ]); - const showWishlist = !!viewerId; + const showWishlist = authState.viewer.isAuthenticated && !!authState.viewer.id; return (
      diff --git a/e-commerce/app/products/[slug]/page.tsx b/e-commerce/app/products/[slug]/page.tsx index 0095400..ee84a37 100644 --- a/e-commerce/app/products/[slug]/page.tsx +++ b/e-commerce/app/products/[slug]/page.tsx @@ -29,7 +29,9 @@ export default async function ProductDetailPage({ const viewerId = authState.viewer.isAuthenticated ? authState.viewer.id : null; const wishlistPromise = viewerId && authState.accessToken - ? getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }) + ? getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }).catch( + () => new Set(), + ) : Promise.resolve(new Set()); const [wishlistIds, allRelated] = await Promise.all([ diff --git a/e-commerce/app/products/page.tsx b/e-commerce/app/products/page.tsx index cc6602a..a9fb17c 100644 --- a/e-commerce/app/products/page.tsx +++ b/e-commerce/app/products/page.tsx @@ -15,18 +15,22 @@ export default async function ProductsPage({ searchParams: Promise<{ category?: string; search?: string }>; }) { const params = await searchParams; - const authState = await getCurrentAuthState(); - const viewerId = authState.viewer.isAuthenticated ? authState.viewer.id : null; - const wishlistPromise = viewerId && authState.accessToken - ? getWishlistProductIds({ accessToken: authState.accessToken, userId: viewerId }) - : Promise.resolve(new Set()); + 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 [categories, products, wishlistIds] = await Promise.all([ + const [authState, categories, products, wishlistIds] = await Promise.all([ + authPromise, getCategories(), getProducts({ category: params.category, search: params.search }), wishlistPromise, ]); - const showWishlist = !!viewerId; + const showWishlist = authState.viewer.isAuthenticated && !!authState.viewer.id; const activeCategory = params.category ?? null; function buildCatalogHref(category?: string) { diff --git a/e-commerce/components/wishlist-button.tsx b/e-commerce/components/wishlist-button.tsx index cca3c0e..7662c98 100644 --- a/e-commerce/components/wishlist-button.tsx +++ b/e-commerce/components/wishlist-button.tsx @@ -16,7 +16,7 @@ export function WishlistButton({ size?: 'sm' | 'md'; }) { const [optimistic, setOptimistic] = useOptimistic(initialInWishlist); - const [, startTransition] = useTransition(); + const [isPending, startTransition] = useTransition(); function handleClick(event: React.MouseEvent) { event.preventDefault(); @@ -37,6 +37,7 @@ export function WishlistButton({