@pylonsync/create-pylon 0.3.274 → 0.3.275
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create-pylon.js +80 -0
- package/package.json +1 -1
- package/templates/ARCHETYPES.md +339 -0
- package/templates/agency/.env.example +12 -0
- package/templates/agency/AGENTS.md +61 -0
- package/templates/agency/README.md +90 -0
- package/templates/agency/app/auth-form.tsx +129 -0
- package/templates/agency/app/contact-form.tsx +258 -0
- package/templates/agency/app/dashboard/dashboard-client.tsx +286 -0
- package/templates/agency/app/dashboard/page.tsx +70 -0
- package/templates/agency/app/error.tsx +26 -0
- package/templates/agency/app/globals.css +148 -0
- package/templates/agency/app/layout.tsx +174 -0
- package/templates/agency/app/login/page.tsx +39 -0
- package/templates/agency/app/not-found.tsx +19 -0
- package/templates/agency/app/page.tsx +207 -0
- package/templates/agency/app/robots.ts +12 -0
- package/templates/agency/app/sitemap.ts +9 -0
- package/templates/agency/app.ts +135 -0
- package/templates/agency/components/marketing.tsx +148 -0
- package/templates/agency/components/section-scroller.tsx +35 -0
- package/templates/agency/components/ui/button.tsx +56 -0
- package/templates/agency/components/ui/card.tsx +90 -0
- package/templates/agency/components.json +20 -0
- package/templates/agency/functions/bookInquiry.ts +42 -0
- package/templates/agency/functions/declineInquiry.ts +41 -0
- package/templates/agency/functions/inquiriesForOwner.ts +31 -0
- package/templates/agency/functions/seedCapacity.ts +26 -0
- package/templates/agency/functions/setCapacity.ts +32 -0
- package/templates/agency/functions/submitInquiry.ts +55 -0
- package/templates/agency/gitignore +10 -0
- package/templates/agency/lib/agency.ts +27 -0
- package/templates/agency/lib/owner.ts +26 -0
- package/templates/agency/lib/site.config.ts +239 -0
- package/templates/agency/lib/utils.ts +10 -0
- package/templates/agency/package.json +34 -0
- package/templates/agency/tsconfig.json +18 -0
- package/templates/ai-chat/.env.example +33 -0
- package/templates/ai-chat/AGENTS.md +61 -0
- package/templates/ai-chat/README.md +99 -0
- package/templates/ai-chat/app/auth-form.tsx +124 -0
- package/templates/ai-chat/app/chat-client.tsx +414 -0
- package/templates/ai-chat/app/error.tsx +26 -0
- package/templates/ai-chat/app/globals.css +148 -0
- package/templates/ai-chat/app/layout.tsx +75 -0
- package/templates/ai-chat/app/login/page.tsx +39 -0
- package/templates/ai-chat/app/not-found.tsx +19 -0
- package/templates/ai-chat/app/page.tsx +23 -0
- package/templates/ai-chat/app.ts +121 -0
- package/templates/ai-chat/components.json +20 -0
- package/templates/ai-chat/gitignore +10 -0
- package/templates/ai-chat/lib/site.config.ts +103 -0
- package/templates/ai-chat/lib/utils.ts +10 -0
- package/templates/ai-chat/package.json +34 -0
- package/templates/ai-chat/tsconfig.json +18 -0
- package/templates/ai-studio/.env.example +19 -0
- package/templates/ai-studio/AGENTS.md +61 -0
- package/templates/ai-studio/README.md +83 -0
- package/templates/ai-studio/app/auth-form.tsx +124 -0
- package/templates/ai-studio/app/error.tsx +26 -0
- package/templates/ai-studio/app/globals.css +148 -0
- package/templates/ai-studio/app/layout.tsx +75 -0
- package/templates/ai-studio/app/login/page.tsx +39 -0
- package/templates/ai-studio/app/not-found.tsx +19 -0
- package/templates/ai-studio/app/page.tsx +34 -0
- package/templates/ai-studio/app/studio-client.tsx +214 -0
- package/templates/ai-studio/app.ts +108 -0
- package/templates/ai-studio/components.json +20 -0
- package/templates/ai-studio/functions/_getGeneration.ts +25 -0
- package/templates/ai-studio/functions/_updateGeneration.ts +37 -0
- package/templates/ai-studio/functions/generate.ts +42 -0
- package/templates/ai-studio/functions/pollGeneration.ts +134 -0
- package/templates/ai-studio/gitignore +10 -0
- package/templates/ai-studio/lib/site.config.ts +80 -0
- package/templates/ai-studio/lib/studio.ts +52 -0
- package/templates/ai-studio/lib/utils.ts +10 -0
- package/templates/ai-studio/package.json +34 -0
- package/templates/ai-studio/tsconfig.json +18 -0
- package/templates/creator/.env.example +12 -0
- package/templates/creator/AGENTS.md +61 -0
- package/templates/creator/README.md +67 -0
- package/templates/creator/app/auth-form.tsx +129 -0
- package/templates/creator/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/creator/app/dashboard/page.tsx +70 -0
- package/templates/creator/app/error.tsx +26 -0
- package/templates/creator/app/globals.css +148 -0
- package/templates/creator/app/layout.tsx +160 -0
- package/templates/creator/app/login/page.tsx +39 -0
- package/templates/creator/app/newsletter-signup.tsx +162 -0
- package/templates/creator/app/not-found.tsx +19 -0
- package/templates/creator/app/page.tsx +160 -0
- package/templates/creator/app/robots.ts +12 -0
- package/templates/creator/app/sitemap.ts +9 -0
- package/templates/creator/app.ts +134 -0
- package/templates/creator/components/marketing.tsx +148 -0
- package/templates/creator/components/section-scroller.tsx +35 -0
- package/templates/creator/components/ui/button.tsx +56 -0
- package/templates/creator/components/ui/card.tsx +90 -0
- package/templates/creator/components.json +20 -0
- package/templates/creator/functions/subscribe.ts +82 -0
- package/templates/creator/functions/subscriberStats.ts +75 -0
- package/templates/creator/gitignore +10 -0
- package/templates/creator/lib/owner.ts +26 -0
- package/templates/creator/lib/site.config.ts +173 -0
- package/templates/creator/lib/stats.ts +30 -0
- package/templates/creator/lib/utils.ts +10 -0
- package/templates/creator/package.json +34 -0
- package/templates/creator/tsconfig.json +18 -0
- package/templates/default/app/layout.tsx +26 -27
- package/templates/default/app/page.tsx +90 -274
- package/templates/default/lib/products.ts +9 -122
- package/templates/default/lib/site.config.ts +739 -0
- package/templates/default/lib/site.ts +14 -261
- package/templates/directory/.env.example +12 -0
- package/templates/directory/AGENTS.md +61 -0
- package/templates/directory/README.md +80 -0
- package/templates/directory/app/auth-form.tsx +129 -0
- package/templates/directory/app/dashboard/dashboard-client.tsx +205 -0
- package/templates/directory/app/dashboard/page.tsx +70 -0
- package/templates/directory/app/directory-browse.tsx +328 -0
- package/templates/directory/app/error.tsx +26 -0
- package/templates/directory/app/globals.css +148 -0
- package/templates/directory/app/layout.tsx +171 -0
- package/templates/directory/app/login/page.tsx +39 -0
- package/templates/directory/app/not-found.tsx +19 -0
- package/templates/directory/app/page.tsx +50 -0
- package/templates/directory/app/robots.ts +12 -0
- package/templates/directory/app/sitemap.ts +9 -0
- package/templates/directory/app/submit/page.tsx +30 -0
- package/templates/directory/app/submit-form.tsx +151 -0
- package/templates/directory/app.ts +146 -0
- package/templates/directory/components/marketing.tsx +148 -0
- package/templates/directory/components/section-scroller.tsx +35 -0
- package/templates/directory/components/ui/button.tsx +56 -0
- package/templates/directory/components/ui/card.tsx +90 -0
- package/templates/directory/components.json +20 -0
- package/templates/directory/functions/approveSubmission.ts +45 -0
- package/templates/directory/functions/rejectSubmission.ts +20 -0
- package/templates/directory/functions/seedListings.ts +33 -0
- package/templates/directory/functions/submissionsForOwner.ts +29 -0
- package/templates/directory/functions/submitListing.ts +63 -0
- package/templates/directory/functions/upvote.ts +24 -0
- package/templates/directory/gitignore +10 -0
- package/templates/directory/lib/directory.ts +45 -0
- package/templates/directory/lib/owner.ts +26 -0
- package/templates/directory/lib/site.config.ts +130 -0
- package/templates/directory/lib/utils.ts +10 -0
- package/templates/directory/package.json +34 -0
- package/templates/directory/tsconfig.json +18 -0
- package/templates/local-service/.env.example +12 -0
- package/templates/local-service/AGENTS.md +61 -0
- package/templates/local-service/README.md +82 -0
- package/templates/local-service/app/auth-form.tsx +129 -0
- package/templates/local-service/app/booking-widget.tsx +399 -0
- package/templates/local-service/app/dashboard/dashboard-client.tsx +304 -0
- package/templates/local-service/app/dashboard/page.tsx +63 -0
- package/templates/local-service/app/error.tsx +26 -0
- package/templates/local-service/app/globals.css +148 -0
- package/templates/local-service/app/layout.tsx +151 -0
- package/templates/local-service/app/login/page.tsx +39 -0
- package/templates/local-service/app/not-found.tsx +19 -0
- package/templates/local-service/app/page.tsx +233 -0
- package/templates/local-service/app/robots.ts +12 -0
- package/templates/local-service/app/sitemap.ts +9 -0
- package/templates/local-service/app.ts +131 -0
- package/templates/local-service/components/marketing.tsx +162 -0
- package/templates/local-service/components/section-scroller.tsx +35 -0
- package/templates/local-service/components/ui/button.tsx +56 -0
- package/templates/local-service/components/ui/card.tsx +90 -0
- package/templates/local-service/components.json +20 -0
- package/templates/local-service/functions/bookingsForOwner.ts +30 -0
- package/templates/local-service/functions/cancelBooking.ts +27 -0
- package/templates/local-service/functions/confirmBooking.ts +18 -0
- package/templates/local-service/functions/createBooking.ts +98 -0
- package/templates/local-service/gitignore +10 -0
- package/templates/local-service/lib/booking.ts +24 -0
- package/templates/local-service/lib/owner.ts +26 -0
- package/templates/local-service/lib/site.config.ts +232 -0
- package/templates/local-service/lib/slots.ts +97 -0
- package/templates/local-service/lib/utils.ts +10 -0
- package/templates/local-service/package.json +34 -0
- package/templates/local-service/tsconfig.json +18 -0
- package/templates/marketplace/.env.example +9 -0
- package/templates/marketplace/AGENTS.md +61 -0
- package/templates/marketplace/README.md +78 -0
- package/templates/marketplace/app/_components/CategoryIcon.tsx +40 -0
- package/templates/marketplace/app/error.tsx +26 -0
- package/templates/marketplace/app/globals.css +64 -0
- package/templates/marketplace/app/layout.tsx +60 -0
- package/templates/marketplace/app/listing/[id]/page.tsx +163 -0
- package/templates/marketplace/app/me/page.tsx +15 -0
- package/templates/marketplace/app/not-found.tsx +20 -0
- package/templates/marketplace/app/page.tsx +159 -0
- package/templates/marketplace/app/robots.ts +12 -0
- package/templates/marketplace/app/sell/page.tsx +26 -0
- package/templates/marketplace/app/sitemap.ts +14 -0
- package/templates/marketplace/app.ts +190 -0
- package/templates/marketplace/client/AuthNav.tsx +46 -0
- package/templates/marketplace/client/LiveTicker.tsx +104 -0
- package/templates/marketplace/client/LoginCard.tsx +130 -0
- package/templates/marketplace/client/MarketProvider.tsx +148 -0
- package/templates/marketplace/client/MyMarket.tsx +180 -0
- package/templates/marketplace/client/OfferPanel.tsx +355 -0
- package/templates/marketplace/client/SeedOnEmpty.tsx +26 -0
- package/templates/marketplace/client/SellForm.tsx +160 -0
- package/templates/marketplace/client/WatchButton.tsx +88 -0
- package/templates/marketplace/client/market.ts +341 -0
- package/templates/marketplace/functions/buyNow.ts +78 -0
- package/templates/marketplace/functions/makeOffer.ts +65 -0
- package/templates/marketplace/functions/respondToOffer.ts +62 -0
- package/templates/marketplace/functions/seedMarket.ts +90 -0
- package/templates/marketplace/gitignore +10 -0
- package/templates/marketplace/package.json +35 -0
- package/templates/marketplace/tsconfig.json +14 -0
- package/templates/marketplace/ui/badge.tsx +30 -0
- package/templates/marketplace/ui/button.tsx +49 -0
- package/templates/marketplace/ui/card.tsx +48 -0
- package/templates/marketplace/ui/input.tsx +17 -0
- package/templates/marketplace/ui/label.tsx +18 -0
- package/templates/marketplace/ui/textarea.tsx +17 -0
- package/templates/marketplace/ui/tokens.css +32 -0
- package/templates/marketplace/ui/utils.ts +6 -0
- package/templates/restaurant/.env.example +12 -0
- package/templates/restaurant/AGENTS.md +61 -0
- package/templates/restaurant/README.md +77 -0
- package/templates/restaurant/app/auth-form.tsx +129 -0
- package/templates/restaurant/app/dashboard/dashboard-client.tsx +263 -0
- package/templates/restaurant/app/dashboard/page.tsx +59 -0
- package/templates/restaurant/app/error.tsx +26 -0
- package/templates/restaurant/app/globals.css +148 -0
- package/templates/restaurant/app/layout.tsx +151 -0
- package/templates/restaurant/app/login/page.tsx +39 -0
- package/templates/restaurant/app/not-found.tsx +19 -0
- package/templates/restaurant/app/page.tsx +194 -0
- package/templates/restaurant/app/reservation-widget.tsx +359 -0
- package/templates/restaurant/app/robots.ts +12 -0
- package/templates/restaurant/app/sitemap.ts +9 -0
- package/templates/restaurant/app.ts +115 -0
- package/templates/restaurant/components/marketing.tsx +162 -0
- package/templates/restaurant/components/section-scroller.tsx +35 -0
- package/templates/restaurant/components/ui/button.tsx +56 -0
- package/templates/restaurant/components/ui/card.tsx +90 -0
- package/templates/restaurant/components.json +20 -0
- package/templates/restaurant/functions/cancelReservation.ts +26 -0
- package/templates/restaurant/functions/confirmReservation.ts +17 -0
- package/templates/restaurant/functions/createReservation.ts +92 -0
- package/templates/restaurant/functions/reservationsForOwner.ts +28 -0
- package/templates/restaurant/gitignore +10 -0
- package/templates/restaurant/lib/owner.ts +26 -0
- package/templates/restaurant/lib/reservation.ts +22 -0
- package/templates/restaurant/lib/site.config.ts +218 -0
- package/templates/restaurant/lib/slots.ts +55 -0
- package/templates/restaurant/lib/utils.ts +10 -0
- package/templates/restaurant/package.json +34 -0
- package/templates/restaurant/tsconfig.json +18 -0
- package/templates/shop/.env.example +32 -0
- package/templates/shop/AGENTS.md +61 -0
- package/templates/shop/README.md +102 -0
- package/templates/shop/app/auth-form.tsx +129 -0
- package/templates/shop/app/dashboard/dashboard-client.tsx +264 -0
- package/templates/shop/app/dashboard/page.tsx +59 -0
- package/templates/shop/app/error.tsx +26 -0
- package/templates/shop/app/globals.css +148 -0
- package/templates/shop/app/layout.tsx +160 -0
- package/templates/shop/app/login/page.tsx +39 -0
- package/templates/shop/app/not-found.tsx +19 -0
- package/templates/shop/app/page.tsx +95 -0
- package/templates/shop/app/robots.ts +12 -0
- package/templates/shop/app/shop-client.tsx +436 -0
- package/templates/shop/app/sitemap.ts +9 -0
- package/templates/shop/app/success/page.tsx +33 -0
- package/templates/shop/app.ts +134 -0
- package/templates/shop/components/marketing.tsx +96 -0
- package/templates/shop/components/section-scroller.tsx +35 -0
- package/templates/shop/components/ui/button.tsx +56 -0
- package/templates/shop/components/ui/card.tsx +90 -0
- package/templates/shop/components.json +20 -0
- package/templates/shop/functions/cancelOrder.ts +33 -0
- package/templates/shop/functions/checkout.ts +130 -0
- package/templates/shop/functions/fulfillOrder.ts +17 -0
- package/templates/shop/functions/markGroupPaid.ts +26 -0
- package/templates/shop/functions/ordersForOwner.ts +28 -0
- package/templates/shop/functions/releaseGroup.ts +36 -0
- package/templates/shop/functions/reserveCart.ts +87 -0
- package/templates/shop/functions/restockProduct.ts +23 -0
- package/templates/shop/functions/seedProducts.ts +30 -0
- package/templates/shop/functions/stripeWebhook.ts +72 -0
- package/templates/shop/gitignore +10 -0
- package/templates/shop/lib/owner.ts +26 -0
- package/templates/shop/lib/shop.ts +45 -0
- package/templates/shop/lib/site.config.ts +198 -0
- package/templates/shop/lib/utils.ts +10 -0
- package/templates/shop/package.json +35 -0
- package/templates/shop/tsconfig.json +18 -0
- package/templates/waitlist/.env.example +12 -0
- package/templates/waitlist/AGENTS.md +61 -0
- package/templates/waitlist/README.md +81 -0
- package/templates/waitlist/app/auth-form.tsx +129 -0
- package/templates/waitlist/app/dashboard/dashboard-client.tsx +297 -0
- package/templates/waitlist/app/dashboard/page.tsx +70 -0
- package/templates/waitlist/app/error.tsx +26 -0
- package/templates/waitlist/app/globals.css +148 -0
- package/templates/waitlist/app/layout.tsx +158 -0
- package/templates/waitlist/app/login/page.tsx +39 -0
- package/templates/waitlist/app/not-found.tsx +19 -0
- package/templates/waitlist/app/page.tsx +119 -0
- package/templates/waitlist/app/robots.ts +12 -0
- package/templates/waitlist/app/sitemap.ts +9 -0
- package/templates/waitlist/app/waitlist-hero.tsx +219 -0
- package/templates/waitlist/app.ts +134 -0
- package/templates/waitlist/components/marketing.tsx +96 -0
- package/templates/waitlist/components/ui/button.tsx +56 -0
- package/templates/waitlist/components/ui/card.tsx +90 -0
- package/templates/waitlist/components.json +20 -0
- package/templates/waitlist/functions/joinWaitlist.ts +82 -0
- package/templates/waitlist/functions/waitlistStats.ts +75 -0
- package/templates/waitlist/gitignore +10 -0
- package/templates/waitlist/lib/owner.ts +26 -0
- package/templates/waitlist/lib/site.config.ts +178 -0
- package/templates/waitlist/lib/stats.ts +30 -0
- package/templates/waitlist/lib/utils.ts +10 -0
- package/templates/waitlist/package.json +34 -0
- package/templates/waitlist/tsconfig.json +18 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { db, callFn } from "@pylonsync/react";
|
|
5
|
+
import { EnsureGuest } from "@pylonsync/client";
|
|
6
|
+
import { siteConfig } from "@/lib/site.config";
|
|
7
|
+
import { formatPrice, type CheckoutResult } from "@/lib/shop";
|
|
8
|
+
|
|
9
|
+
// The storefront — the realtime heart of this template. The grid subscribes to
|
|
10
|
+
// the public Product entity with `db.useQuery`, so every card's stock ("3 left")
|
|
11
|
+
// ticks down — and flips to "Sold out" — for everyone with the page open the
|
|
12
|
+
// instant someone checks out. Shopping is a normal cart: add items, then check
|
|
13
|
+
// out (name + email). The `checkout` action holds stock under a per-product lock
|
|
14
|
+
// (so the cart can't oversell) and, when Stripe is configured, hands the browser
|
|
15
|
+
// off to a hosted Stripe Checkout page; otherwise it records a reserved order.
|
|
16
|
+
//
|
|
17
|
+
// Wrapped in <EnsureGuest> so the sync connection is established for anonymous
|
|
18
|
+
// visitors. The guest session holds no PII and can't read the Order table; the
|
|
19
|
+
// grid only ever reads the public catalog + stock.
|
|
20
|
+
|
|
21
|
+
interface ProductRow {
|
|
22
|
+
id: string;
|
|
23
|
+
slug: string;
|
|
24
|
+
name: string;
|
|
25
|
+
priceCents: number;
|
|
26
|
+
description?: string | null;
|
|
27
|
+
image: string;
|
|
28
|
+
stock: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ShopGrid() {
|
|
32
|
+
return (
|
|
33
|
+
<EnsureGuest fallback={<GridSkeleton />}>
|
|
34
|
+
<Shop />
|
|
35
|
+
</EnsureGuest>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function Shop() {
|
|
40
|
+
const { data: products, loading } = db.useQuery<ProductRow>("Product");
|
|
41
|
+
const [cart, setCart] = useState<Record<string, number>>({}); // slug → qty
|
|
42
|
+
const [checkingOut, setCheckingOut] = useState(false);
|
|
43
|
+
const [placed, setPlaced] = useState(false);
|
|
44
|
+
|
|
45
|
+
// Seed the catalog from config on first visit (idempotent server-side).
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
void callFn("seedProducts", {});
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const bySlug = useMemo(() => new Map(products.map((p) => [p.slug, p])), [products]);
|
|
51
|
+
const ordered = useMemo(() => {
|
|
52
|
+
const idx = new Map(siteConfig.products.items.map((p, i) => [p.slug, i]));
|
|
53
|
+
return [...products].sort((a, b) => (idx.get(a.slug) ?? 99) - (idx.get(b.slug) ?? 99));
|
|
54
|
+
}, [products]);
|
|
55
|
+
|
|
56
|
+
function setQty(slug: string, qty: number) {
|
|
57
|
+
const stock = bySlug.get(slug)?.stock ?? 0;
|
|
58
|
+
const clamped = Math.max(0, Math.min(qty, stock));
|
|
59
|
+
setCart((c) => {
|
|
60
|
+
const next = { ...c };
|
|
61
|
+
if (clamped <= 0) delete next[slug];
|
|
62
|
+
else next[slug] = clamped;
|
|
63
|
+
return next;
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Cart entries that still reference a real product, clamped to live stock.
|
|
68
|
+
const entries = Object.entries(cart)
|
|
69
|
+
.map(([slug, qty]) => {
|
|
70
|
+
const p = bySlug.get(slug);
|
|
71
|
+
return p ? { product: p, qty: Math.min(qty, p.stock) } : null;
|
|
72
|
+
})
|
|
73
|
+
.filter((x): x is { product: ProductRow; qty: number } => !!x && x.qty > 0);
|
|
74
|
+
const itemCount = entries.reduce((s, e) => s + e.qty, 0);
|
|
75
|
+
const total = entries.reduce((s, e) => s + e.product.priceCents * e.qty, 0);
|
|
76
|
+
|
|
77
|
+
if (loading && products.length === 0) return <GridSkeleton />;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
82
|
+
{ordered.map((p) => (
|
|
83
|
+
<ProductCard
|
|
84
|
+
key={p.id}
|
|
85
|
+
product={p}
|
|
86
|
+
qty={cart[p.slug] ?? 0}
|
|
87
|
+
onAdd={() => setQty(p.slug, (cart[p.slug] ?? 0) + 1)}
|
|
88
|
+
onSet={(q) => setQty(p.slug, q)}
|
|
89
|
+
/>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Sticky cart bar */}
|
|
94
|
+
{itemCount > 0 ? (
|
|
95
|
+
<div className="pointer-events-none fixed inset-x-0 bottom-0 z-40 px-4 pb-4">
|
|
96
|
+
<div className="pointer-events-auto mx-auto flex max-w-md items-center justify-between gap-4 rounded-full border border-zinc-200 bg-white px-5 py-3 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.3)]">
|
|
97
|
+
<span className="text-[14px] font-medium text-zinc-900">
|
|
98
|
+
{itemCount} {itemCount === 1 ? "item" : "items"}
|
|
99
|
+
<span className="ml-2 text-zinc-400">·</span>
|
|
100
|
+
<span className="ml-2 tabular-nums">{formatPrice(total)}</span>
|
|
101
|
+
</span>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={() => {
|
|
105
|
+
setPlaced(false);
|
|
106
|
+
setCheckingOut(true);
|
|
107
|
+
}}
|
|
108
|
+
className="inline-flex h-9 items-center rounded-full bg-brand px-5 text-[14px] font-medium text-white transition-opacity hover:opacity-90"
|
|
109
|
+
>
|
|
110
|
+
Checkout →
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
) : null}
|
|
115
|
+
|
|
116
|
+
{/* Checkout modal */}
|
|
117
|
+
{checkingOut ? (
|
|
118
|
+
<CheckoutModal
|
|
119
|
+
entries={entries}
|
|
120
|
+
total={total}
|
|
121
|
+
placed={placed}
|
|
122
|
+
onClose={() => setCheckingOut(false)}
|
|
123
|
+
onSetQty={setQty}
|
|
124
|
+
onPlaced={(succeededSlugs) => {
|
|
125
|
+
setCart((c) => {
|
|
126
|
+
const next = { ...c };
|
|
127
|
+
succeededSlugs.forEach((s) => delete next[s]);
|
|
128
|
+
return next;
|
|
129
|
+
});
|
|
130
|
+
setPlaced(true);
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
) : null}
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function ProductCard({
|
|
139
|
+
product,
|
|
140
|
+
qty,
|
|
141
|
+
onAdd,
|
|
142
|
+
onSet,
|
|
143
|
+
}: {
|
|
144
|
+
product: ProductRow;
|
|
145
|
+
qty: number;
|
|
146
|
+
onAdd: () => void;
|
|
147
|
+
onSet: (q: number) => void;
|
|
148
|
+
}) {
|
|
149
|
+
const soldOut = product.stock <= 0;
|
|
150
|
+
return (
|
|
151
|
+
<div className="flex flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white">
|
|
152
|
+
<ProductImage image={product.image} soldOut={soldOut} />
|
|
153
|
+
<div className="flex flex-1 flex-col p-5">
|
|
154
|
+
<div className="flex items-start justify-between gap-3">
|
|
155
|
+
<h3 className="text-[15px] font-semibold text-zinc-900">{product.name}</h3>
|
|
156
|
+
<span className="shrink-0 text-[15px] font-semibold text-zinc-900">
|
|
157
|
+
{formatPrice(product.priceCents)}
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
160
|
+
{product.description ? (
|
|
161
|
+
<p className="mt-1.5 text-[13px] leading-relaxed text-zinc-500">{product.description}</p>
|
|
162
|
+
) : null}
|
|
163
|
+
|
|
164
|
+
<div className="mt-3">
|
|
165
|
+
<StockBadge stock={product.stock} />
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div className="mt-4">
|
|
169
|
+
{qty > 0 ? (
|
|
170
|
+
<div className="flex items-center justify-between rounded-lg border border-zinc-200 px-1.5 py-1">
|
|
171
|
+
<button
|
|
172
|
+
type="button"
|
|
173
|
+
onClick={() => onSet(qty - 1)}
|
|
174
|
+
aria-label="Decrease quantity"
|
|
175
|
+
className="flex size-7 items-center justify-center rounded-md text-zinc-600 hover:bg-zinc-100"
|
|
176
|
+
>
|
|
177
|
+
−
|
|
178
|
+
</button>
|
|
179
|
+
<span className="text-[14px] font-medium tabular-nums">{qty} in cart</span>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={onAdd}
|
|
183
|
+
disabled={qty >= product.stock}
|
|
184
|
+
aria-label="Increase quantity"
|
|
185
|
+
className="flex size-7 items-center justify-center rounded-md text-zinc-600 hover:bg-zinc-100 disabled:opacity-30"
|
|
186
|
+
>
|
|
187
|
+
+
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
) : (
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
disabled={soldOut}
|
|
194
|
+
onClick={onAdd}
|
|
195
|
+
className={
|
|
196
|
+
"inline-flex h-10 w-full items-center justify-center rounded-lg text-sm font-medium transition-colors " +
|
|
197
|
+
(soldOut
|
|
198
|
+
? "cursor-not-allowed bg-zinc-100 text-zinc-400"
|
|
199
|
+
: "bg-brand text-white hover:opacity-90")
|
|
200
|
+
}
|
|
201
|
+
>
|
|
202
|
+
{soldOut ? "Sold out" : "Add to cart"}
|
|
203
|
+
</button>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Product image. `image` in lib/site.config.ts is either a real photo URL (or
|
|
212
|
+
// /public path) — rendered as an <img> — or an emoji stand-in, which we render
|
|
213
|
+
// big AND tag "sample image" so it's obvious it's a placeholder to replace with
|
|
214
|
+
// a real product photo. Swap the config value for a URL and the photo shows up.
|
|
215
|
+
function ProductImage({ image, soldOut }: { image: string; soldOut: boolean }) {
|
|
216
|
+
const isPhoto = /^(https?:\/\/|\/)/.test(image);
|
|
217
|
+
if (isPhoto) {
|
|
218
|
+
return (
|
|
219
|
+
<div className="aspect-[4/3] overflow-hidden bg-paper">
|
|
220
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
221
|
+
<img
|
|
222
|
+
src={image}
|
|
223
|
+
alt=""
|
|
224
|
+
className={"size-full object-cover " + (soldOut ? "opacity-40 grayscale" : "")}
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return (
|
|
230
|
+
<div className="relative grid aspect-[4/3] place-items-center bg-paper">
|
|
231
|
+
<span className={"text-6xl " + (soldOut ? "opacity-40 grayscale" : "")}>{image}</span>
|
|
232
|
+
<span className="absolute left-2 top-2 rounded-full border border-dashed border-zinc-300 bg-white/70 px-2 py-0.5 text-[10px] font-medium text-zinc-400">
|
|
233
|
+
sample image
|
|
234
|
+
</span>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function StockBadge({ stock }: { stock: number }) {
|
|
240
|
+
if (stock <= 0) {
|
|
241
|
+
return (
|
|
242
|
+
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-100 px-2.5 py-1 text-[12px] font-medium text-zinc-500">
|
|
243
|
+
Sold out
|
|
244
|
+
</span>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
const low = stock <= 3;
|
|
248
|
+
return (
|
|
249
|
+
<span
|
|
250
|
+
className={
|
|
251
|
+
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[12px] font-medium " +
|
|
252
|
+
(low ? "bg-red-50 text-red-600" : "bg-green-50 text-green-700")
|
|
253
|
+
}
|
|
254
|
+
>
|
|
255
|
+
<span className="relative flex size-1.5">
|
|
256
|
+
<span className={"absolute inline-flex size-1.5 animate-ping rounded-full " + (low ? "bg-red-500/70" : "bg-green-500/60")} />
|
|
257
|
+
<span className={"relative inline-flex size-1.5 rounded-full " + (low ? "bg-red-500" : "bg-green-600")} />
|
|
258
|
+
</span>
|
|
259
|
+
{low ? `Only ${stock} left` : `${stock} in stock`}
|
|
260
|
+
</span>
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function CheckoutModal({
|
|
265
|
+
entries,
|
|
266
|
+
total,
|
|
267
|
+
placed,
|
|
268
|
+
onClose,
|
|
269
|
+
onSetQty,
|
|
270
|
+
onPlaced,
|
|
271
|
+
}: {
|
|
272
|
+
entries: { product: ProductRow; qty: number }[];
|
|
273
|
+
total: number;
|
|
274
|
+
placed: boolean;
|
|
275
|
+
onClose: () => void;
|
|
276
|
+
onSetQty: (slug: string, qty: number) => void;
|
|
277
|
+
onPlaced: (succeededSlugs: string[]) => void;
|
|
278
|
+
}) {
|
|
279
|
+
const [name, setName] = useState("");
|
|
280
|
+
const [email, setEmail] = useState("");
|
|
281
|
+
const [status, setStatus] = useState<"idle" | "placing">("idle");
|
|
282
|
+
const [error, setError] = useState<string | null>(null);
|
|
283
|
+
const [soldOutNote, setSoldOutNote] = useState<string[]>([]);
|
|
284
|
+
|
|
285
|
+
async function submitCheckout(e: React.FormEvent) {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
if (status === "placing") return;
|
|
288
|
+
if (!name.trim() || !email.trim()) {
|
|
289
|
+
setError("Name and email are required.");
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
setStatus("placing");
|
|
293
|
+
setError(null);
|
|
294
|
+
|
|
295
|
+
const origin = window.location.origin;
|
|
296
|
+
let res: CheckoutResult;
|
|
297
|
+
try {
|
|
298
|
+
res = await callFn<CheckoutResult>("checkout", {
|
|
299
|
+
items: entries.map((it) => ({ slug: it.product.slug, qty: it.qty })),
|
|
300
|
+
customerName: name.trim(),
|
|
301
|
+
customerEmail: email.trim(),
|
|
302
|
+
successUrl: `${origin}/success`,
|
|
303
|
+
cancelUrl: `${origin}/#shop`,
|
|
304
|
+
});
|
|
305
|
+
} catch (err) {
|
|
306
|
+
setStatus("idle");
|
|
307
|
+
setError(err instanceof Error ? err.message : "Checkout failed. Please try again.");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (res.ok && res.mode === "stripe") {
|
|
312
|
+
// Hand off to Stripe's hosted checkout — keep the button busy while the
|
|
313
|
+
// browser navigates away. Stock is already held; the webhook settles it.
|
|
314
|
+
window.location.href = res.checkoutUrl;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
setStatus("idle");
|
|
319
|
+
if (res.ok && res.mode === "reserved") {
|
|
320
|
+
// No payment processor configured — the order is held for the owner.
|
|
321
|
+
setSoldOutNote(res.soldOut);
|
|
322
|
+
onPlaced(entries.map((it) => it.product.slug));
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// res.ok === false
|
|
326
|
+
if (res.reason === "sold_out") {
|
|
327
|
+
setError(`Sold out before checkout: ${res.soldOut.join(", ") || "those items"}. Nothing was ordered.`);
|
|
328
|
+
} else if (res.reason === "empty") {
|
|
329
|
+
setError("Your cart is empty.");
|
|
330
|
+
} else {
|
|
331
|
+
setError("Could not place the order. Please check your details.");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return (
|
|
336
|
+
<div className="fixed inset-0 z-50 flex items-end justify-center bg-zinc-900/40 p-4 sm:items-center">
|
|
337
|
+
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
|
338
|
+
{placed && entries.length === 0 ? (
|
|
339
|
+
<div className="text-center">
|
|
340
|
+
<div className="mx-auto flex size-10 items-center justify-center rounded-full bg-brand text-white">✓</div>
|
|
341
|
+
<p className="mt-3 text-[15px] font-semibold text-zinc-900">
|
|
342
|
+
{siteConfig.checkout.confirmationMessage}
|
|
343
|
+
</p>
|
|
344
|
+
{soldOutNote.length > 0 ? (
|
|
345
|
+
<p className="mt-2 text-[13px] text-amber-600">
|
|
346
|
+
{soldOutNote.join(", ")} sold out before we could reserve {soldOutNote.length === 1 ? "it" : "them"}.
|
|
347
|
+
</p>
|
|
348
|
+
) : null}
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
onClick={onClose}
|
|
352
|
+
className="mt-5 inline-flex h-10 items-center justify-center rounded-lg border border-zinc-300 px-5 text-sm font-medium text-zinc-700 hover:bg-zinc-50"
|
|
353
|
+
>
|
|
354
|
+
Keep shopping
|
|
355
|
+
</button>
|
|
356
|
+
</div>
|
|
357
|
+
) : (
|
|
358
|
+
<>
|
|
359
|
+
<div className="flex items-center justify-between">
|
|
360
|
+
<h2 className="text-base font-semibold text-zinc-900">Your cart</h2>
|
|
361
|
+
<button type="button" onClick={onClose} aria-label="Close" className="text-zinc-400 hover:text-zinc-700">
|
|
362
|
+
✕
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<ul className="mt-4 space-y-3">
|
|
367
|
+
{entries.map(({ product, qty }) => (
|
|
368
|
+
<li key={product.slug} className="flex items-center gap-3">
|
|
369
|
+
<span className="grid size-10 shrink-0 place-items-center rounded-lg bg-paper text-2xl">{product.image}</span>
|
|
370
|
+
<div className="min-w-0 flex-1">
|
|
371
|
+
<div className="truncate text-[14px] font-medium text-zinc-900">{product.name}</div>
|
|
372
|
+
<div className="text-[12.5px] text-zinc-500">{formatPrice(product.priceCents)} each</div>
|
|
373
|
+
</div>
|
|
374
|
+
<div className="flex items-center rounded-md border border-zinc-200">
|
|
375
|
+
<button type="button" onClick={() => onSetQty(product.slug, qty - 1)} className="px-2 py-1 text-zinc-500 hover:text-zinc-900">−</button>
|
|
376
|
+
<span className="min-w-5 text-center text-[13px] font-medium tabular-nums">{qty}</span>
|
|
377
|
+
<button type="button" onClick={() => onSetQty(product.slug, qty + 1)} disabled={qty >= product.stock} className="px-2 py-1 text-zinc-500 hover:text-zinc-900 disabled:opacity-30">+</button>
|
|
378
|
+
</div>
|
|
379
|
+
<span className="w-16 shrink-0 text-right text-[14px] font-medium tabular-nums">
|
|
380
|
+
{formatPrice(product.priceCents * qty)}
|
|
381
|
+
</span>
|
|
382
|
+
</li>
|
|
383
|
+
))}
|
|
384
|
+
</ul>
|
|
385
|
+
|
|
386
|
+
{entries.length === 0 ? (
|
|
387
|
+
<p className="mt-4 text-center text-sm text-zinc-500">Your cart is empty.</p>
|
|
388
|
+
) : (
|
|
389
|
+
<form onSubmit={submitCheckout} className="mt-5 border-t border-zinc-100 pt-4">
|
|
390
|
+
<div className="flex items-center justify-between text-[15px] font-semibold text-zinc-900">
|
|
391
|
+
<span>Total</span>
|
|
392
|
+
<span className="tabular-nums">{formatPrice(total)}</span>
|
|
393
|
+
</div>
|
|
394
|
+
<div className="mt-4 grid gap-2.5">
|
|
395
|
+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Your name" aria-label="Your name" autoComplete="name" className={inputCls} />
|
|
396
|
+
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@email.com" aria-label="Email" autoComplete="email" className={inputCls} />
|
|
397
|
+
</div>
|
|
398
|
+
{error ? <p className="mt-2 text-[13px] text-red-600">{error}</p> : null}
|
|
399
|
+
<button
|
|
400
|
+
type="submit"
|
|
401
|
+
disabled={status === "placing"}
|
|
402
|
+
className="mt-4 inline-flex h-11 w-full items-center justify-center rounded-lg bg-brand text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60"
|
|
403
|
+
>
|
|
404
|
+
{status === "placing" ? "Taking you to checkout…" : `Checkout · ${formatPrice(total)}`}
|
|
405
|
+
</button>
|
|
406
|
+
<p className="mt-2 text-center text-[12px] text-zinc-400">
|
|
407
|
+
Secure checkout. You'll confirm payment on the next step.
|
|
408
|
+
</p>
|
|
409
|
+
</form>
|
|
410
|
+
)}
|
|
411
|
+
</>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const inputCls =
|
|
419
|
+
"h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-sm text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20";
|
|
420
|
+
|
|
421
|
+
function GridSkeleton() {
|
|
422
|
+
return (
|
|
423
|
+
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
424
|
+
{Array.from({ length: 6 }).map((_, i) => (
|
|
425
|
+
<div key={i} className="overflow-hidden rounded-2xl border border-zinc-200 bg-white">
|
|
426
|
+
<div className="aspect-[4/3] animate-pulse bg-zinc-100" />
|
|
427
|
+
<div className="space-y-2 p-5">
|
|
428
|
+
<div className="h-4 w-2/3 animate-pulse rounded bg-zinc-100" />
|
|
429
|
+
<div className="h-3 w-full animate-pulse rounded bg-zinc-100" />
|
|
430
|
+
<div className="h-10 w-full animate-pulse rounded-lg bg-zinc-100" />
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
))}
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Sitemap } from "@pylonsync/react";
|
|
2
|
+
|
|
3
|
+
// app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
|
|
4
|
+
// production. The waitlist is a single public page, so the sitemap is just "/".
|
|
5
|
+
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
6
|
+
|
|
7
|
+
export default async function sitemap(): Promise<Sitemap> {
|
|
8
|
+
return [{ url: `${SITE}/`, changeFrequency: "weekly", priority: 1 }];
|
|
9
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, type Metadata } from "@pylonsync/react";
|
|
3
|
+
import { WRAP } from "@/components/marketing";
|
|
4
|
+
import { siteConfig } from "@/lib/site.config";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: `Thank you — ${siteConfig.brand.name}`,
|
|
8
|
+
description: "Your order is confirmed.",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// `app/success/page.tsx` → `/success`. Where Stripe sends the shopper after a
|
|
12
|
+
// completed Checkout (the `successUrl` passed to the `checkout` action). The
|
|
13
|
+
// order is settled server-side by the signed `stripeWebhook` — this page is
|
|
14
|
+
// just the friendly landing, so it doesn't need to read the session id Stripe
|
|
15
|
+
// appends. Rendered with the normal marketing chrome (nav + footer).
|
|
16
|
+
export default function CheckoutSuccess() {
|
|
17
|
+
return (
|
|
18
|
+
<section className={`${WRAP} flex min-h-[60vh] flex-col items-center justify-center py-20 text-center`}>
|
|
19
|
+
<div className="flex size-12 items-center justify-center rounded-full bg-brand text-xl text-white">✓</div>
|
|
20
|
+
<h1 className="mt-5 text-2xl font-semibold tracking-tight text-zinc-900">Thank you for your order</h1>
|
|
21
|
+
<p className="mx-auto mt-3 max-w-md text-[15px] leading-relaxed text-zinc-500">
|
|
22
|
+
Your payment went through and we've emailed your receipt. We'll be in touch when it ships —
|
|
23
|
+
everything is hand-made, so give us a couple of business days.
|
|
24
|
+
</p>
|
|
25
|
+
<Link
|
|
26
|
+
href="/#shop"
|
|
27
|
+
className="mt-7 inline-flex h-11 items-center rounded-full bg-brand px-6 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
|
28
|
+
>
|
|
29
|
+
Keep shopping
|
|
30
|
+
</Link>
|
|
31
|
+
</section>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entity,
|
|
3
|
+
field,
|
|
4
|
+
policy,
|
|
5
|
+
auth,
|
|
6
|
+
buildManifest,
|
|
7
|
+
discoverAppRoutes,
|
|
8
|
+
} from "@pylonsync/sdk";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// shop — a small DTC store with LIVE inventory + real Stripe checkout. The
|
|
12
|
+
// realtime hook: each product shows its stock ("3 left"), and the moment
|
|
13
|
+
// someone buys the last one it flips to "Sold out" for EVERYONE with the page
|
|
14
|
+
// open — no refresh. The server holds stock at checkout (under a per-product
|
|
15
|
+
// lock) so two shoppers can't both buy the last unit.
|
|
16
|
+
//
|
|
17
|
+
// • Product — the catalog row, INCLUDING live stock. No PII, so it's
|
|
18
|
+
// PUBLIC-READ: the grid reads it directly with `db.useQuery`,
|
|
19
|
+
// which is what makes the stock count live. Seeded from config
|
|
20
|
+
// on first visit. Clients can't write it; checkout holds stock
|
|
21
|
+
// and restockProduct / cancelOrder return it, all server-side.
|
|
22
|
+
// • Order — a placed order line, with the customer's name + email. Holds
|
|
23
|
+
// PII → denies ALL client reads/writes. Lines from one cart
|
|
24
|
+
// share an `orderGroupId` (the Stripe Checkout Session's
|
|
25
|
+
// client_reference_id) so the webhook settles them together.
|
|
26
|
+
// • User — the owner's account for the dashboard.
|
|
27
|
+
//
|
|
28
|
+
// Payments: the `checkout` action opens a Stripe Checkout Session when
|
|
29
|
+
// STRIPE_SECRET_KEY is set, and the `stripeWebhook` action (signature-verified,
|
|
30
|
+
// mounted at /api/webhooks/stripeWebhook) flips orders to "paid" or releases
|
|
31
|
+
// held stock on expiry. With NO Stripe key the store still works end-to-end:
|
|
32
|
+
// checkout holds the stock and records a "reserved" order the owner follows up
|
|
33
|
+
// on — so the template boots and demos live inventory with zero config.
|
|
34
|
+
//
|
|
35
|
+
// Order status: "pending" (awaiting Stripe payment, stock held) →
|
|
36
|
+
// "paid" (Stripe confirmed) | "reserved" (no Stripe; manual follow-up, stock
|
|
37
|
+
// held) → "fulfilled" (shipped) | "cancelled" (released, stock returned).
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const Product = entity(
|
|
41
|
+
"Product",
|
|
42
|
+
{
|
|
43
|
+
slug: field.string(),
|
|
44
|
+
name: field.string(),
|
|
45
|
+
priceCents: field.int(),
|
|
46
|
+
description: field.string().optional(),
|
|
47
|
+
image: field.string().optional(), // emoji or image URL
|
|
48
|
+
stock: field.int().default(0),
|
|
49
|
+
},
|
|
50
|
+
{ indexes: [{ name: "by_slug", fields: ["slug"], unique: true }] },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const Order = entity(
|
|
54
|
+
"Order",
|
|
55
|
+
{
|
|
56
|
+
orderGroupId: field.string(), // one cart → many lines, settled together
|
|
57
|
+
productSlug: field.string(),
|
|
58
|
+
productName: field.string(),
|
|
59
|
+
qty: field.int(),
|
|
60
|
+
unitPriceCents: field.int(),
|
|
61
|
+
customerName: field.string(),
|
|
62
|
+
customerEmail: field.string(),
|
|
63
|
+
// "pending" | "paid" | "reserved" | "fulfilled" | "cancelled"
|
|
64
|
+
status: field.string().default("pending"),
|
|
65
|
+
createdAt: field.datetime().defaultNow(),
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
indexes: [
|
|
69
|
+
{ name: "by_created", fields: ["createdAt"], unique: false },
|
|
70
|
+
{ name: "by_group", fields: ["orderGroupId"], unique: false },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const User = entity(
|
|
76
|
+
"User",
|
|
77
|
+
{
|
|
78
|
+
email: field.string(),
|
|
79
|
+
displayName: field.string().optional(),
|
|
80
|
+
passwordHash: field.string().serverOnly().optional(),
|
|
81
|
+
avatarColor: field.string().optional(),
|
|
82
|
+
emailVerified: field.datetime().optional(),
|
|
83
|
+
createdAt: field.datetime().defaultNow(),
|
|
84
|
+
},
|
|
85
|
+
{ indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Catalog + stock are public to READ — the whole point is the grid showing live
|
|
89
|
+
// stock to everyone. Clients can't WRITE; only seedProducts / checkout /
|
|
90
|
+
// restockProduct / cancelOrder maintain it server-side.
|
|
91
|
+
const productPolicy = policy({
|
|
92
|
+
name: "product_public_read",
|
|
93
|
+
entity: "Product",
|
|
94
|
+
allowRead: "true",
|
|
95
|
+
allowInsert: "false",
|
|
96
|
+
allowUpdate: "false",
|
|
97
|
+
allowDelete: "false",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// PRIVACY — Order holds the customer's name + email, so it denies EVERY client
|
|
101
|
+
// read and write. The public page never reads an Order; the owner reads them
|
|
102
|
+
// only through the owner-gated `ordersForOwner`.
|
|
103
|
+
const orderPolicy = policy({
|
|
104
|
+
name: "order_private",
|
|
105
|
+
entity: "Order",
|
|
106
|
+
allowRead: "false",
|
|
107
|
+
allowInsert: "false",
|
|
108
|
+
allowUpdate: "false",
|
|
109
|
+
allowDelete: "false",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const userPolicy = policy({
|
|
113
|
+
name: "user_self",
|
|
114
|
+
entity: "User",
|
|
115
|
+
allowRead: "auth.userId == data.id",
|
|
116
|
+
allowInsert: "false",
|
|
117
|
+
allowUpdate: "false",
|
|
118
|
+
allowDelete: "false",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const manifest = buildManifest({
|
|
122
|
+
name: "__APP_NAME__",
|
|
123
|
+
version: "0.1.0",
|
|
124
|
+
entities: [Product, Order, User],
|
|
125
|
+
queries: [],
|
|
126
|
+
actions: [],
|
|
127
|
+
policies: [productPolicy, orderPolicy, userPolicy],
|
|
128
|
+
auth: auth(),
|
|
129
|
+
routes: await discoverAppRoutes(),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
133
|
+
|
|
134
|
+
export default manifest;
|