@pylonsync/create-pylon 0.3.273 → 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,96 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
// Reusable presentational pieces for the landing page. All server-rendered —
|
|
4
|
+
// no client JS. Restyle here and the whole page follows. The brand accent
|
|
5
|
+
// (`text-brand`, `bg-brand-soft`) comes from CSS vars set on <html> in
|
|
6
|
+
// app/layout.tsx, which read lib/site.config.ts — so re-theming is one edit.
|
|
7
|
+
|
|
8
|
+
// Shared container: a contained, centered column.
|
|
9
|
+
export const WRAP = "mx-auto w-full max-w-3xl px-6";
|
|
10
|
+
|
|
11
|
+
export function Eyebrow({ children }: { children: React.ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-brand">
|
|
14
|
+
{children}
|
|
15
|
+
</p>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// "New / Coming soon"-style pill for the hero.
|
|
20
|
+
export function Badge({ children }: { children: React.ReactNode }) {
|
|
21
|
+
return (
|
|
22
|
+
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1.5 pr-3 text-[13px] text-zinc-600 shadow-sm">
|
|
23
|
+
<span className="inline-block size-1.5 rounded-full bg-brand" />
|
|
24
|
+
{children}
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Divider() {
|
|
30
|
+
return (
|
|
31
|
+
<div className={WRAP}>
|
|
32
|
+
<div className="border-t border-zinc-200/70" />
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function SectionHead({
|
|
38
|
+
eyebrow,
|
|
39
|
+
title,
|
|
40
|
+
body,
|
|
41
|
+
}: {
|
|
42
|
+
eyebrow: string;
|
|
43
|
+
title: string;
|
|
44
|
+
body?: string;
|
|
45
|
+
}) {
|
|
46
|
+
return (
|
|
47
|
+
<div>
|
|
48
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
49
|
+
<h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
|
|
50
|
+
{title}
|
|
51
|
+
</h2>
|
|
52
|
+
{body ? (
|
|
53
|
+
<p className="mt-4 max-w-xl text-[15px] leading-relaxed text-zinc-500">
|
|
54
|
+
{body}
|
|
55
|
+
</p>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A grid of value props — icon + title + body.
|
|
62
|
+
export function FeatureGrid({
|
|
63
|
+
items,
|
|
64
|
+
}: {
|
|
65
|
+
items: { title: string; body: string; icon?: string }[];
|
|
66
|
+
}) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="grid gap-6 sm:grid-cols-3">
|
|
69
|
+
{items.map((f) => (
|
|
70
|
+
<div key={f.title}>
|
|
71
|
+
{f.icon ? (
|
|
72
|
+
<span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
|
|
73
|
+
{f.icon}
|
|
74
|
+
</span>
|
|
75
|
+
) : null}
|
|
76
|
+
<h3 className="mt-4 text-[15px] font-semibold text-zinc-900">
|
|
77
|
+
{f.title}
|
|
78
|
+
</h3>
|
|
79
|
+
<p className="mt-2 text-[14px] leading-relaxed text-zinc-500">
|
|
80
|
+
{f.body}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Initials for testimonial avatars, so the cards look finished without a photo.
|
|
89
|
+
export function initials(name: string) {
|
|
90
|
+
return name
|
|
91
|
+
.split(/\s+/)
|
|
92
|
+
.map((w) => w[0])
|
|
93
|
+
.join("")
|
|
94
|
+
.slice(0, 2)
|
|
95
|
+
.toUpperCase();
|
|
96
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
// Makes in-page section links work. A hydrated Pylon page updates the URL for a
|
|
6
|
+
// plain `<a href="#section">` click but doesn't perform the browser's native
|
|
7
|
+
// fragment scroll, so the page jumps nowhere. This installs ONE delegated click
|
|
8
|
+
// handler that catches any same-page `#`/`/#` anchor and scrolls to it smoothly.
|
|
9
|
+
//
|
|
10
|
+
// Render it once (in the root layout). Renders nothing. Real route links should
|
|
11
|
+
// still use `<Link>` from @pylonsync/react — this only handles `#` anchors.
|
|
12
|
+
export function SectionScroller() {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
function onClick(e: MouseEvent) {
|
|
15
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const target = e.target as Element | null;
|
|
19
|
+
const link = target?.closest?.('a[href^="#"], a[href^="/#"]') as HTMLAnchorElement | null;
|
|
20
|
+
if (!link) return;
|
|
21
|
+
const href = link.getAttribute("href") || "";
|
|
22
|
+
const id = href.slice(href.indexOf("#") + 1);
|
|
23
|
+
if (!id) return;
|
|
24
|
+
const el = document.getElementById(id);
|
|
25
|
+
if (!el) return; // target not on this page — leave it to the browser
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
el.scrollIntoView({ block: "start" });
|
|
28
|
+
history.replaceState(null, "", "#" + id);
|
|
29
|
+
}
|
|
30
|
+
document.addEventListener("click", onClick);
|
|
31
|
+
return () => document.removeEventListener("click", onClick);
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
const buttonVariants = cva(
|
|
8
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
13
|
+
destructive:
|
|
14
|
+
"bg-destructive text-white hover:bg-destructive/90",
|
|
15
|
+
outline:
|
|
16
|
+
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
|
17
|
+
secondary:
|
|
18
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
19
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
20
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
default: "h-9 px-4 py-2",
|
|
24
|
+
sm: "h-8 rounded-md px-3 text-xs",
|
|
25
|
+
lg: "h-10 rounded-md px-8",
|
|
26
|
+
icon: "h-9 w-9",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
defaultVariants: {
|
|
30
|
+
variant: "default",
|
|
31
|
+
size: "default",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export interface ButtonProps
|
|
37
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
38
|
+
VariantProps<typeof buttonVariants> {
|
|
39
|
+
asChild?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
43
|
+
({ className, variant, size, asChild = false, ...props }, ref) => {
|
|
44
|
+
const Comp = asChild ? Slot : "button";
|
|
45
|
+
return (
|
|
46
|
+
<Comp
|
|
47
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
48
|
+
ref={ref}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
Button.displayName = "Button";
|
|
55
|
+
|
|
56
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@/lib/utils";
|
|
4
|
+
|
|
5
|
+
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
data-slot="card"
|
|
9
|
+
className={cn(
|
|
10
|
+
"rounded-xl border bg-card text-card-foreground shadow-sm",
|
|
11
|
+
className,
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function CardHeader({
|
|
19
|
+
className,
|
|
20
|
+
...props
|
|
21
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
22
|
+
return (
|
|
23
|
+
<div
|
|
24
|
+
data-slot="card-header"
|
|
25
|
+
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function CardTitle({
|
|
32
|
+
className,
|
|
33
|
+
...props
|
|
34
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
data-slot="card-title"
|
|
38
|
+
className={cn("font-semibold leading-none tracking-tight", className)}
|
|
39
|
+
{...props}
|
|
40
|
+
/>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function CardDescription({
|
|
45
|
+
className,
|
|
46
|
+
...props
|
|
47
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
data-slot="card-description"
|
|
51
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
52
|
+
{...props}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function CardContent({
|
|
58
|
+
className,
|
|
59
|
+
...props
|
|
60
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
data-slot="card-content"
|
|
64
|
+
className={cn("p-6 pt-0", className)}
|
|
65
|
+
{...props}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function CardFooter({
|
|
71
|
+
className,
|
|
72
|
+
...props
|
|
73
|
+
}: React.HTMLAttributes<HTMLDivElement>) {
|
|
74
|
+
return (
|
|
75
|
+
<div
|
|
76
|
+
data-slot="card-footer"
|
|
77
|
+
className={cn("flex items-center p-6 pt-0", className)}
|
|
78
|
+
{...props}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export {
|
|
84
|
+
Card,
|
|
85
|
+
CardHeader,
|
|
86
|
+
CardFooter,
|
|
87
|
+
CardTitle,
|
|
88
|
+
CardDescription,
|
|
89
|
+
CardContent,
|
|
90
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "new-york",
|
|
4
|
+
"rsc": false,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "app/globals.css",
|
|
9
|
+
"baseColor": "zinc",
|
|
10
|
+
"cssVariables": true
|
|
11
|
+
},
|
|
12
|
+
"aliases": {
|
|
13
|
+
"components": "@/components",
|
|
14
|
+
"utils": "@/lib/utils",
|
|
15
|
+
"ui": "@/components/ui",
|
|
16
|
+
"lib": "@/lib",
|
|
17
|
+
"hooks": "@/hooks"
|
|
18
|
+
},
|
|
19
|
+
"iconLibrary": "lucide"
|
|
20
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// cancelOrder — owner-only. Marks the order cancelled AND returns its units to
|
|
5
|
+
// stock: the Product.stock bump syncs to every open grid, so "N left" ticks
|
|
6
|
+
// back up (and a sold-out item can come back) live.
|
|
7
|
+
export default mutation<{ orderId: string }, { ok: boolean }>({
|
|
8
|
+
auth: "user",
|
|
9
|
+
args: { orderId: v.id("Order") },
|
|
10
|
+
async handler(ctx, args) {
|
|
11
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
12
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
13
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage orders.");
|
|
14
|
+
}
|
|
15
|
+
const order = (await ctx.db.get("Order", args.orderId)) as
|
|
16
|
+
| { productSlug: string; qty: number; status: string }
|
|
17
|
+
| null;
|
|
18
|
+
if (!order) throw ctx.error("NOT_FOUND", "Order not found.");
|
|
19
|
+
|
|
20
|
+
await ctx.db.unsafe.update("Order", args.orderId, { status: "cancelled" });
|
|
21
|
+
|
|
22
|
+
// Restore the units (only if it wasn't already cancelled).
|
|
23
|
+
if (order.status !== "cancelled") {
|
|
24
|
+
const product = (await ctx.db.unsafe.lookup("Product", "slug", order.productSlug)) as
|
|
25
|
+
| { id: string; stock: number }
|
|
26
|
+
| null;
|
|
27
|
+
if (product) {
|
|
28
|
+
await ctx.db.unsafe.update("Product", product.id, { stock: product.stock + order.qty });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { ok: true };
|
|
32
|
+
},
|
|
33
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { action, v } from "@pylonsync/functions";
|
|
2
|
+
import { stripeRequest, assertSafeRedirectUrl } from "@pylonsync/stripe";
|
|
3
|
+
import type { CheckoutResult } from "../lib/shop";
|
|
4
|
+
|
|
5
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
|
+
const CURRENCY = "usd";
|
|
7
|
+
// Stripe requires a Checkout Session to expire ≥30 min out; we hold the cart's
|
|
8
|
+
// stock until then, and the `checkout.session.expired` webhook returns it.
|
|
9
|
+
const SESSION_TTL_SECS = 35 * 60;
|
|
10
|
+
|
|
11
|
+
// checkout — turn a cart into a real order. An `action` (not a mutation) because
|
|
12
|
+
// it does network I/O (the Stripe API) on top of the DB write:
|
|
13
|
+
//
|
|
14
|
+
// 1. validate the cart + customer,
|
|
15
|
+
// 2. `runMutation("reserveCart")` to HOLD stock and record the order lines
|
|
16
|
+
// (race-safe, transactional — see reserveCart.ts), and
|
|
17
|
+
// 3. if STRIPE_SECRET_KEY is set, open a Stripe Checkout Session and hand the
|
|
18
|
+
// browser its URL; otherwise leave the order "reserved" for the owner.
|
|
19
|
+
//
|
|
20
|
+
// `auth: "public"` — a shopper has no account. PRIVACY: it returns only a redirect
|
|
21
|
+
// URL / status, never an Order row or anyone's email. The price is taken from the
|
|
22
|
+
// catalog inside reserveCart, never trusted from the client.
|
|
23
|
+
export default action<
|
|
24
|
+
{
|
|
25
|
+
items: { slug: string; qty: number }[];
|
|
26
|
+
customerName: string;
|
|
27
|
+
customerEmail: string;
|
|
28
|
+
successUrl: string;
|
|
29
|
+
cancelUrl: string;
|
|
30
|
+
},
|
|
31
|
+
CheckoutResult
|
|
32
|
+
>({
|
|
33
|
+
auth: "public",
|
|
34
|
+
args: {
|
|
35
|
+
items: v.array(v.object({ slug: v.string(), qty: v.int() })),
|
|
36
|
+
customerName: v.string(),
|
|
37
|
+
customerEmail: v.string(),
|
|
38
|
+
successUrl: v.string(),
|
|
39
|
+
cancelUrl: v.string(),
|
|
40
|
+
},
|
|
41
|
+
async handler(ctx, args): Promise<CheckoutResult> {
|
|
42
|
+
const name = args.customerName.trim();
|
|
43
|
+
const email = args.customerEmail.trim().toLowerCase();
|
|
44
|
+
if (name.length < 1 || name.length > 120) {
|
|
45
|
+
throw ctx.error("INVALID_ARGS", "Enter your name.");
|
|
46
|
+
}
|
|
47
|
+
if (!EMAIL_RE.test(email) || email.length > 254) {
|
|
48
|
+
throw ctx.error("INVALID_ARGS", "Enter a valid email address.");
|
|
49
|
+
}
|
|
50
|
+
const items = args.items.filter((i) => i && i.slug && Math.trunc(i.qty) >= 1);
|
|
51
|
+
if (items.length === 0) return { ok: false, reason: "empty", soldOut: [] };
|
|
52
|
+
|
|
53
|
+
const secretKey = ctx.env.STRIPE_SECRET_KEY?.trim();
|
|
54
|
+
const stripeOn = Boolean(secretKey);
|
|
55
|
+
|
|
56
|
+
// One id ties the cart's order lines together AND becomes the Stripe
|
|
57
|
+
// session's client_reference_id, so the webhook settles them as a unit.
|
|
58
|
+
const orderGroupId = crypto.randomUUID();
|
|
59
|
+
|
|
60
|
+
const held = await ctx.runMutation<{
|
|
61
|
+
ok: boolean;
|
|
62
|
+
lines: { slug: string; name: string; qty: number; unitPriceCents: number }[];
|
|
63
|
+
soldOut: string[];
|
|
64
|
+
}>("reserveCart", {
|
|
65
|
+
orderGroupId,
|
|
66
|
+
initialStatus: stripeOn ? "pending" : "reserved",
|
|
67
|
+
customerName: name,
|
|
68
|
+
customerEmail: email,
|
|
69
|
+
items,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
if (!held.ok || held.lines.length === 0) {
|
|
73
|
+
return { ok: false, reason: "sold_out", soldOut: held.soldOut };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// No payment processor → the order is held as "reserved"; the owner follows
|
|
77
|
+
// up with a payment link. The store still works end-to-end with zero config.
|
|
78
|
+
if (!stripeOn) {
|
|
79
|
+
return { ok: true, mode: "reserved", orderGroupId, soldOut: held.soldOut };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Stripe is configured → open a hosted Checkout Session. `price_data` prices
|
|
83
|
+
// each line inline, so there are no Stripe Price objects to pre-create.
|
|
84
|
+
// assertSafeRedirectUrl blocks an attacker-supplied success/cancel URL from
|
|
85
|
+
// pointing the post-payment redirect off to a malicious host: it allows the
|
|
86
|
+
// app's own host (PYLON_PUBLIC_URL / SITE_URL, plus localhost in dev).
|
|
87
|
+
const publicUrl = ctx.env.PYLON_PUBLIC_URL || ctx.env.SITE_URL;
|
|
88
|
+
const urlOpts = { extraOrigins: ctx.env.PYLON_CORS_ORIGIN };
|
|
89
|
+
assertSafeRedirectUrl(args.successUrl, publicUrl, urlOpts);
|
|
90
|
+
assertSafeRedirectUrl(args.cancelUrl, publicUrl, urlOpts);
|
|
91
|
+
|
|
92
|
+
// If the session can't be created (bad key, network error, Stripe down) we
|
|
93
|
+
// MUST release the held stock — there's no session that would ever expire to
|
|
94
|
+
// do it for us, so without this the units would be stranded off the shelf.
|
|
95
|
+
let session: { id: string; url: string } | undefined;
|
|
96
|
+
try {
|
|
97
|
+
session = await stripeRequest<{ id: string; url: string }>(
|
|
98
|
+
{ secretKey: secretKey as string, idempotencyKey: orderGroupId },
|
|
99
|
+
"POST",
|
|
100
|
+
"/checkout/sessions",
|
|
101
|
+
{
|
|
102
|
+
mode: "payment",
|
|
103
|
+
success_url: args.successUrl,
|
|
104
|
+
cancel_url: args.cancelUrl,
|
|
105
|
+
client_reference_id: orderGroupId,
|
|
106
|
+
customer_email: email,
|
|
107
|
+
expires_at: Math.floor(Date.now() / 1000) + SESSION_TTL_SECS,
|
|
108
|
+
metadata: { orderGroupId },
|
|
109
|
+
line_items: held.lines.map((l) => ({
|
|
110
|
+
quantity: l.qty,
|
|
111
|
+
price_data: {
|
|
112
|
+
currency: CURRENCY,
|
|
113
|
+
unit_amount: l.unitPriceCents,
|
|
114
|
+
product_data: { name: l.name },
|
|
115
|
+
},
|
|
116
|
+
})),
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
} catch {
|
|
120
|
+
session = undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!session?.url) {
|
|
124
|
+
await ctx.runMutation("releaseGroup", { orderGroupId });
|
|
125
|
+
throw ctx.error("STRIPE_ERROR", "Could not start checkout. Please try again.");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { ok: true, mode: "stripe", checkoutUrl: session.url, soldOut: held.soldOut };
|
|
129
|
+
},
|
|
130
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// fulfillOrder — owner-only. Marks a reserved order fulfilled (shipped). Stock
|
|
5
|
+
// already came off the shelf at order time, so nothing else changes.
|
|
6
|
+
export default mutation<{ orderId: string }, { ok: boolean }>({
|
|
7
|
+
auth: "user",
|
|
8
|
+
args: { orderId: v.id("Order") },
|
|
9
|
+
async handler(ctx, args) {
|
|
10
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
11
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
12
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage orders.");
|
|
13
|
+
}
|
|
14
|
+
await ctx.db.unsafe.update("Order", args.orderId, { status: "fulfilled" });
|
|
15
|
+
return { ok: true };
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
// markGroupPaid — flip a cart's order lines from "pending" to "paid" once Stripe
|
|
4
|
+
// confirms the payment. Called only by the stripeWebhook action (internal), and
|
|
5
|
+
// idempotent: it only touches rows still "pending", so a re-delivered webhook
|
|
6
|
+
// (Stripe retries) is a no-op. Stock was already held at checkout, so nothing
|
|
7
|
+
// else changes here.
|
|
8
|
+
export default mutation<{ orderGroupId: string }, { ok: boolean; updated: number }>({
|
|
9
|
+
internal: true,
|
|
10
|
+
args: { orderGroupId: v.string() },
|
|
11
|
+
async handler(ctx, args) {
|
|
12
|
+
const rows = (await ctx.db.unsafe.list("Order")) as unknown as {
|
|
13
|
+
id: string;
|
|
14
|
+
orderGroupId: string;
|
|
15
|
+
status: string;
|
|
16
|
+
}[];
|
|
17
|
+
let updated = 0;
|
|
18
|
+
for (const o of rows) {
|
|
19
|
+
if (o.orderGroupId === args.orderGroupId && o.status === "pending") {
|
|
20
|
+
await ctx.db.unsafe.update("Order", o.id, { status: "paid" });
|
|
21
|
+
updated++;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { ok: true, updated };
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { query } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import type { OrderRow, OwnerOrdersResult } from "../lib/shop";
|
|
4
|
+
|
|
5
|
+
// ordersForOwner — the owner's view of every order, INCLUDING the customer's
|
|
6
|
+
// name + email. The one function allowed to return that PII, gated to the
|
|
7
|
+
// configured owner (PYLON_OWNER_EMAIL via ctx.env).
|
|
8
|
+
//
|
|
9
|
+
// The dashboard calls it with `callFn` and re-fetches whenever the live, public
|
|
10
|
+
// Product set changes (stock moves on every order) — so new orders show up
|
|
11
|
+
// without a refresh, while contact details never travel over entity sync.
|
|
12
|
+
export default query({
|
|
13
|
+
auth: "user",
|
|
14
|
+
async handler(ctx): Promise<OwnerOrdersResult> {
|
|
15
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
16
|
+
const email = (me?.email as string | undefined) ?? null;
|
|
17
|
+
if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
18
|
+
return { authorized: false };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const rows = (await ctx.db.unsafe.list("Order")) as unknown as OrderRow[];
|
|
22
|
+
const orders = rows
|
|
23
|
+
.map((r) => ({ ...r }))
|
|
24
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
|
25
|
+
|
|
26
|
+
return { authorized: true, orders };
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
// releaseGroup — return a cart's held stock to the shelf and mark its lines
|
|
4
|
+
// "cancelled". Called when a Stripe Checkout Session expires or its payment
|
|
5
|
+
// fails (via the webhook), or when checkout itself couldn't open the session.
|
|
6
|
+
// Internal + idempotent: it only acts on rows still "pending" (stock still
|
|
7
|
+
// held), so it never double-restores. The Product.stock bump syncs to every
|
|
8
|
+
// open grid, so "Sold out" can flip back to "N left" live.
|
|
9
|
+
export default mutation<{ orderGroupId: string }, { ok: boolean; restored: number }>({
|
|
10
|
+
internal: true,
|
|
11
|
+
args: { orderGroupId: v.string() },
|
|
12
|
+
async handler(ctx, args) {
|
|
13
|
+
const rows = (await ctx.db.unsafe.list("Order")) as unknown as {
|
|
14
|
+
id: string;
|
|
15
|
+
orderGroupId: string;
|
|
16
|
+
productSlug: string;
|
|
17
|
+
qty: number;
|
|
18
|
+
status: string;
|
|
19
|
+
}[];
|
|
20
|
+
let restored = 0;
|
|
21
|
+
for (const o of rows) {
|
|
22
|
+
if (o.orderGroupId !== args.orderGroupId || o.status !== "pending") continue;
|
|
23
|
+
|
|
24
|
+
await ctx.db.advisoryLock(`shop_product:${o.productSlug}`);
|
|
25
|
+
const product = (await ctx.db.unsafe.lookup("Product", "slug", o.productSlug)) as
|
|
26
|
+
| { id: string; stock: number }
|
|
27
|
+
| null;
|
|
28
|
+
if (product) {
|
|
29
|
+
await ctx.db.unsafe.update("Product", product.id, { stock: product.stock + o.qty });
|
|
30
|
+
}
|
|
31
|
+
await ctx.db.unsafe.update("Order", o.id, { status: "cancelled" });
|
|
32
|
+
restored += o.qty;
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, restored };
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
// reserveCart — hold stock for a whole cart and record its order lines, all in
|
|
4
|
+
// ONE transaction. Called only from the `checkout` action (never the public
|
|
5
|
+
// HTTP endpoint), so it's `internal: true`: it trusts its args because the
|
|
6
|
+
// action already validated them and decided the initial status.
|
|
7
|
+
//
|
|
8
|
+
// For each line it takes a per-product advisory lock, re-checks stock, and
|
|
9
|
+
// DECREMENTS Product.stock — so two carts can't both claim the last unit, and
|
|
10
|
+
// every open grid sees "N left" tick down live. A line whose product sold out
|
|
11
|
+
// in the meantime is skipped and reported in `soldOut` (partial carts still go
|
|
12
|
+
// through). The Order rows it inserts all share `orderGroupId` (= the Stripe
|
|
13
|
+
// Checkout Session's client_reference_id) so the webhook can settle them as one.
|
|
14
|
+
//
|
|
15
|
+
// `initialStatus` is set by the caller: "pending" when a Stripe payment is
|
|
16
|
+
// coming, "reserved" when there's no payment processor and the owner follows up.
|
|
17
|
+
export default mutation<
|
|
18
|
+
{
|
|
19
|
+
orderGroupId: string;
|
|
20
|
+
initialStatus: "pending" | "reserved";
|
|
21
|
+
customerName: string;
|
|
22
|
+
customerEmail: string;
|
|
23
|
+
items: { slug: string; qty: number }[];
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
ok: boolean;
|
|
27
|
+
lines: { slug: string; name: string; qty: number; unitPriceCents: number }[];
|
|
28
|
+
soldOut: string[];
|
|
29
|
+
}
|
|
30
|
+
>({
|
|
31
|
+
internal: true,
|
|
32
|
+
args: {
|
|
33
|
+
orderGroupId: v.string(),
|
|
34
|
+
initialStatus: v.string(),
|
|
35
|
+
customerName: v.string(),
|
|
36
|
+
customerEmail: v.string(),
|
|
37
|
+
items: v.array(
|
|
38
|
+
v.object({
|
|
39
|
+
slug: v.string(),
|
|
40
|
+
qty: v.int(),
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
},
|
|
44
|
+
async handler(ctx, args) {
|
|
45
|
+
const name = args.customerName.trim();
|
|
46
|
+
const email = args.customerEmail.trim().toLowerCase();
|
|
47
|
+
const lines: { slug: string; name: string; qty: number; unitPriceCents: number }[] = [];
|
|
48
|
+
const soldOut: string[] = [];
|
|
49
|
+
|
|
50
|
+
for (const item of args.items) {
|
|
51
|
+
const qty = Math.trunc(item.qty);
|
|
52
|
+
if (!Number.isFinite(qty) || qty < 1) continue;
|
|
53
|
+
|
|
54
|
+
// Serialize against other orders for this product so the re-check can't
|
|
55
|
+
// race; held until the tx commits.
|
|
56
|
+
await ctx.db.advisoryLock(`shop_product:${item.slug}`);
|
|
57
|
+
|
|
58
|
+
const product = (await ctx.db.unsafe.lookup("Product", "slug", item.slug)) as
|
|
59
|
+
| { id: string; name: string; priceCents: number; stock: number }
|
|
60
|
+
| null;
|
|
61
|
+
if (!product) continue;
|
|
62
|
+
if (product.stock <= 0) {
|
|
63
|
+
soldOut.push(product.name);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Clamp to what's actually on the shelf; if some are left, take those.
|
|
68
|
+
const take = Math.min(qty, product.stock);
|
|
69
|
+
await ctx.db.unsafe.update("Product", product.id, { stock: product.stock - take });
|
|
70
|
+
await ctx.db.unsafe.insert("Order", {
|
|
71
|
+
orderGroupId: args.orderGroupId,
|
|
72
|
+
productSlug: item.slug,
|
|
73
|
+
productName: product.name,
|
|
74
|
+
qty: take,
|
|
75
|
+
unitPriceCents: product.priceCents,
|
|
76
|
+
customerName: name,
|
|
77
|
+
customerEmail: email,
|
|
78
|
+
status: args.initialStatus,
|
|
79
|
+
createdAt: new Date().toISOString(),
|
|
80
|
+
});
|
|
81
|
+
lines.push({ slug: item.slug, name: product.name, qty: take, unitPriceCents: product.priceCents });
|
|
82
|
+
if (take < qty) soldOut.push(product.name);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { ok: lines.length > 0, lines, soldOut };
|
|
86
|
+
},
|
|
87
|
+
});
|