@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,129 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
passwordLogin,
|
|
6
|
+
passwordRegister,
|
|
7
|
+
persistSession,
|
|
8
|
+
ApiError,
|
|
9
|
+
} from "@pylonsync/client";
|
|
10
|
+
|
|
11
|
+
// The owner's email/password form — one form, two modes. It calls the built-in
|
|
12
|
+
// auth API directly (`passwordLogin` / `passwordRegister` POST to
|
|
13
|
+
// `/api/auth/password/*`), then `persistSession` writes the freshly-minted
|
|
14
|
+
// token to local storage so the sync engine + `callFn` authenticate AS THE
|
|
15
|
+
// OWNER on the next load. This step matters here specifically: the landing page
|
|
16
|
+
// mints an anonymous guest session (for the live counter), and without
|
|
17
|
+
// persisting the real session that stale guest token would shadow the owner's
|
|
18
|
+
// — so the owner-only `waitlistStats` call would come back as a guest and get
|
|
19
|
+
// rejected. We then do a full navigation to /dashboard so the SSR runtime
|
|
20
|
+
// re-resolves auth from the HttpOnly cookie and renders server-side.
|
|
21
|
+
//
|
|
22
|
+
// A waitlist is single-tenant: there's no public signup funnel, just the owner
|
|
23
|
+
// creating their one account. Whoever signs in only sees data if their email
|
|
24
|
+
// matches PYLON_OWNER_EMAIL — enforced by the waitlistStats function.
|
|
25
|
+
export function AuthForm() {
|
|
26
|
+
const [mode, setMode] = useState<"login" | "signup">("login");
|
|
27
|
+
const [email, setEmail] = useState("");
|
|
28
|
+
const [password, setPassword] = useState("");
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
const [pending, setPending] = useState(false);
|
|
31
|
+
|
|
32
|
+
async function onSubmit(e: React.FormEvent) {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
setError(null);
|
|
35
|
+
setPending(true);
|
|
36
|
+
try {
|
|
37
|
+
const session =
|
|
38
|
+
mode === "login"
|
|
39
|
+
? await passwordLogin({ email, password })
|
|
40
|
+
: await passwordRegister({ email, password });
|
|
41
|
+
// Make this session authoritative, replacing any anonymous guest token.
|
|
42
|
+
persistSession(session);
|
|
43
|
+
window.location.assign("/dashboard");
|
|
44
|
+
} catch (err) {
|
|
45
|
+
setError(messageFor(err));
|
|
46
|
+
setPending(false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="space-y-5">
|
|
52
|
+
<form onSubmit={onSubmit} className="space-y-4">
|
|
53
|
+
<label className="block">
|
|
54
|
+
<span className="mb-1.5 block text-[13px] font-medium text-zinc-700">Email</span>
|
|
55
|
+
<input
|
|
56
|
+
type="email"
|
|
57
|
+
value={email}
|
|
58
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
59
|
+
required
|
|
60
|
+
autoComplete="email"
|
|
61
|
+
placeholder="you@yourbusiness.com"
|
|
62
|
+
className="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"
|
|
63
|
+
/>
|
|
64
|
+
</label>
|
|
65
|
+
<label className="block">
|
|
66
|
+
<span className="mb-1.5 block text-[13px] font-medium text-zinc-700">Password</span>
|
|
67
|
+
<input
|
|
68
|
+
type="password"
|
|
69
|
+
value={password}
|
|
70
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
71
|
+
required
|
|
72
|
+
autoComplete={mode === "login" ? "current-password" : "new-password"}
|
|
73
|
+
placeholder={mode === "login" ? "Your password" : "At least 10 characters"}
|
|
74
|
+
className="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"
|
|
75
|
+
/>
|
|
76
|
+
</label>
|
|
77
|
+
{error ? (
|
|
78
|
+
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-[13px] leading-snug text-red-700">
|
|
79
|
+
{error}
|
|
80
|
+
</p>
|
|
81
|
+
) : null}
|
|
82
|
+
<button
|
|
83
|
+
type="submit"
|
|
84
|
+
disabled={pending}
|
|
85
|
+
className="inline-flex h-10 w-full items-center justify-center rounded-lg bg-zinc-900 text-sm font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-60"
|
|
86
|
+
>
|
|
87
|
+
{pending ? "…" : mode === "login" ? "Sign in" : "Create account"}
|
|
88
|
+
</button>
|
|
89
|
+
</form>
|
|
90
|
+
|
|
91
|
+
<p className="text-center text-[13px] text-zinc-500">
|
|
92
|
+
{mode === "login" ? "First time here?" : "Already have an account?"}{" "}
|
|
93
|
+
<button
|
|
94
|
+
type="button"
|
|
95
|
+
onClick={() => {
|
|
96
|
+
setMode(mode === "login" ? "signup" : "login");
|
|
97
|
+
setError(null);
|
|
98
|
+
}}
|
|
99
|
+
className="font-medium text-zinc-900 underline underline-offset-2"
|
|
100
|
+
>
|
|
101
|
+
{mode === "login" ? "Create the owner account" : "Sign in"}
|
|
102
|
+
</button>
|
|
103
|
+
</p>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Map the framework's auth error codes to friendly copy. `ApiError` carries a
|
|
109
|
+
// stable `.code` so you branch on the code, not the message.
|
|
110
|
+
function messageFor(err: unknown): string {
|
|
111
|
+
if (err instanceof ApiError) {
|
|
112
|
+
switch (err.code) {
|
|
113
|
+
case "INVALID_CREDENTIALS":
|
|
114
|
+
return "Wrong email or password.";
|
|
115
|
+
case "USER_EXISTS":
|
|
116
|
+
return "That email is already registered — sign in instead.";
|
|
117
|
+
case "WEAK_PASSWORD":
|
|
118
|
+
return "Pick a longer password — at least 10 characters.";
|
|
119
|
+
case "PWNED_PASSWORD":
|
|
120
|
+
return "That password has appeared in a known data breach. Choose a different one.";
|
|
121
|
+
case "RATE_LIMITED":
|
|
122
|
+
return "Too many attempts — try again in a minute.";
|
|
123
|
+
default:
|
|
124
|
+
return err.message;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (err instanceof Error) return err.message;
|
|
128
|
+
return "Something went wrong. Try again.";
|
|
129
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { 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 { slotsForDay, weekdayOf, localDateKey, type Slot } from "@/lib/slots";
|
|
8
|
+
|
|
9
|
+
// The live booking picker — the realtime heart of this template. It subscribes
|
|
10
|
+
// to the public, PII-free `BookedSlot` projection with `db.useQuery`, so the
|
|
11
|
+
// instant anyone books a time (this tab or another), that slot greys out for
|
|
12
|
+
// everyone with the page open. The server independently re-checks at insert
|
|
13
|
+
// time, so even a dead-heat double-click can't double-book.
|
|
14
|
+
//
|
|
15
|
+
// Wrapped in <EnsureGuest> so the sync connection (which the live query rides)
|
|
16
|
+
// is established for anonymous visitors. The guest session holds no PII, and
|
|
17
|
+
// the Booking table stays unreadable to it — the picker only ever reads busy
|
|
18
|
+
// time ranges, never a customer's name or email.
|
|
19
|
+
|
|
20
|
+
interface BookedSlotRow {
|
|
21
|
+
id: string;
|
|
22
|
+
serviceSlug: string;
|
|
23
|
+
startsAt: string;
|
|
24
|
+
endsAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function BookingWidget() {
|
|
28
|
+
return (
|
|
29
|
+
<EnsureGuest fallback={<PickerSkeleton />}>
|
|
30
|
+
<Picker />
|
|
31
|
+
</EnsureGuest>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function Picker() {
|
|
36
|
+
const { services, booking } = siteConfig;
|
|
37
|
+
const { data: busyRows, loading } = db.useQuery<BookedSlotRow>("BookedSlot");
|
|
38
|
+
|
|
39
|
+
// Stamp "now" once so slot availability + the day list are stable across
|
|
40
|
+
// re-renders (and only computed client-side, post-hydration).
|
|
41
|
+
const [nowMs] = useState(() => Date.now());
|
|
42
|
+
|
|
43
|
+
const openDays = useMemo(() => {
|
|
44
|
+
const days: string[] = [];
|
|
45
|
+
for (let i = 0; i < booking.daysAhead; i++) {
|
|
46
|
+
const key = localDateKey(i, nowMs);
|
|
47
|
+
if (booking.hours[weekdayOf(key)]) days.push(key);
|
|
48
|
+
}
|
|
49
|
+
return days;
|
|
50
|
+
}, [booking.daysAhead, booking.hours, nowMs]);
|
|
51
|
+
|
|
52
|
+
const [serviceSlug, setServiceSlug] = useState(services.items[0]?.slug ?? "");
|
|
53
|
+
const [day, setDay] = useState(openDays[0] ?? "");
|
|
54
|
+
const [selected, setSelected] = useState<Slot | null>(null);
|
|
55
|
+
// The slot we just successfully booked — kept separate from `selected` so the
|
|
56
|
+
// confirmation card persists even after the live update marks the slot taken.
|
|
57
|
+
const [confirmed, setConfirmed] = useState<{ slot: Slot; serviceName: string } | null>(null);
|
|
58
|
+
|
|
59
|
+
const service = services.items.find((s) => s.slug === serviceSlug) ?? services.items[0];
|
|
60
|
+
|
|
61
|
+
const slots = useMemo(() => {
|
|
62
|
+
if (!service || !day) return [];
|
|
63
|
+
const hrs = booking.hours[weekdayOf(day)];
|
|
64
|
+
if (!hrs) return [];
|
|
65
|
+
return slotsForDay({
|
|
66
|
+
dayISODate: day,
|
|
67
|
+
open: hrs.open,
|
|
68
|
+
close: hrs.close,
|
|
69
|
+
slotMinutes: booking.slotMinutes,
|
|
70
|
+
durationMin: service.durationMin,
|
|
71
|
+
leadTimeHours: booking.leadTimeHours,
|
|
72
|
+
busy: busyRows.map((b) => ({ startsAt: b.startsAt, endsAt: b.endsAt })),
|
|
73
|
+
nowMs,
|
|
74
|
+
});
|
|
75
|
+
// busyRows identity changes on every sync push → slots recompute live.
|
|
76
|
+
}, [service, day, booking, busyRows, nowMs]);
|
|
77
|
+
|
|
78
|
+
// Clear an IN-PROGRESS selection if its slot just got taken out from under us
|
|
79
|
+
// (someone else booked it while this visitor was filling the form). A slot we
|
|
80
|
+
// successfully booked ourselves lives in `confirmed`, not `selected`, so this
|
|
81
|
+
// never wipes our own confirmation.
|
|
82
|
+
const selectedStillFree =
|
|
83
|
+
selected && slots.find((s) => s.startsAt === selected.startsAt)?.available;
|
|
84
|
+
if (selected && !selectedStillFree) {
|
|
85
|
+
queueMicrotask(() => setSelected(null));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pick(slot: Slot) {
|
|
89
|
+
setConfirmed(null);
|
|
90
|
+
setSelected(slot);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm sm:p-7">
|
|
95
|
+
{/* Service picker */}
|
|
96
|
+
<div className="flex flex-wrap gap-2">
|
|
97
|
+
{services.items.map((s) => {
|
|
98
|
+
const active = s.slug === serviceSlug;
|
|
99
|
+
return (
|
|
100
|
+
<button
|
|
101
|
+
key={s.slug}
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={() => {
|
|
104
|
+
setServiceSlug(s.slug);
|
|
105
|
+
setSelected(null);
|
|
106
|
+
setConfirmed(null);
|
|
107
|
+
}}
|
|
108
|
+
className={
|
|
109
|
+
"rounded-full border px-3.5 py-1.5 text-[13px] font-medium transition-colors " +
|
|
110
|
+
(active
|
|
111
|
+
? "border-zinc-900 bg-zinc-900 text-white"
|
|
112
|
+
: "border-zinc-300 text-zinc-700 hover:border-zinc-400")
|
|
113
|
+
}
|
|
114
|
+
>
|
|
115
|
+
{s.name}
|
|
116
|
+
<span className={active ? "ml-1.5 text-white/60" : "ml-1.5 text-zinc-400"}>
|
|
117
|
+
{s.price} · {s.durationMin}m
|
|
118
|
+
</span>
|
|
119
|
+
</button>
|
|
120
|
+
);
|
|
121
|
+
})}
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
{/* Day picker */}
|
|
125
|
+
<div className="mt-5 flex gap-2 overflow-x-auto pb-1">
|
|
126
|
+
{openDays.map((key) => {
|
|
127
|
+
const active = key === day;
|
|
128
|
+
return (
|
|
129
|
+
<button
|
|
130
|
+
key={key}
|
|
131
|
+
type="button"
|
|
132
|
+
onClick={() => {
|
|
133
|
+
setDay(key);
|
|
134
|
+
setSelected(null);
|
|
135
|
+
setConfirmed(null);
|
|
136
|
+
}}
|
|
137
|
+
className={
|
|
138
|
+
"flex shrink-0 flex-col items-center rounded-xl border px-3 py-2 transition-colors " +
|
|
139
|
+
(active
|
|
140
|
+
? "border-brand bg-brand-soft"
|
|
141
|
+
: "border-zinc-200 hover:border-zinc-300")
|
|
142
|
+
}
|
|
143
|
+
>
|
|
144
|
+
<span className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">
|
|
145
|
+
{dowOfKey(key)}
|
|
146
|
+
</span>
|
|
147
|
+
<span
|
|
148
|
+
className={
|
|
149
|
+
"text-[15px] font-semibold " + (active ? "text-brand" : "text-zinc-900")
|
|
150
|
+
}
|
|
151
|
+
>
|
|
152
|
+
{dayOfKey(key)}
|
|
153
|
+
</span>
|
|
154
|
+
</button>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
{/* Slots */}
|
|
160
|
+
<div className="mt-5 border-t border-zinc-100 pt-5">
|
|
161
|
+
{loading ? (
|
|
162
|
+
<SlotsSkeleton />
|
|
163
|
+
) : slots.length === 0 ? (
|
|
164
|
+
<p className="py-6 text-center text-sm text-zinc-500">
|
|
165
|
+
Closed that day — pick another.
|
|
166
|
+
</p>
|
|
167
|
+
) : (
|
|
168
|
+
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
|
|
169
|
+
{slots.map((slot) => {
|
|
170
|
+
const isSelected = selected?.startsAt === slot.startsAt;
|
|
171
|
+
return (
|
|
172
|
+
<button
|
|
173
|
+
key={slot.startsAt}
|
|
174
|
+
type="button"
|
|
175
|
+
disabled={!slot.available}
|
|
176
|
+
onClick={() => pick(slot)}
|
|
177
|
+
aria-pressed={isSelected}
|
|
178
|
+
className={
|
|
179
|
+
"rounded-lg border py-2 text-[13px] font-medium tabular-nums transition-colors " +
|
|
180
|
+
(!slot.available
|
|
181
|
+
? "cursor-not-allowed border-zinc-100 bg-zinc-50 text-zinc-300 line-through"
|
|
182
|
+
: isSelected
|
|
183
|
+
? "border-brand bg-brand text-white"
|
|
184
|
+
: "border-zinc-200 text-zinc-800 hover:border-brand hover:text-brand")
|
|
185
|
+
}
|
|
186
|
+
>
|
|
187
|
+
{labelTime(slot.startsAt)}
|
|
188
|
+
</button>
|
|
189
|
+
);
|
|
190
|
+
})}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
<p className="mt-3 text-[12px] text-zinc-400">
|
|
194
|
+
Greyed-out times are already booked — this updates live as others book.
|
|
195
|
+
</p>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
{/* Confirmation persists after a successful booking… */}
|
|
199
|
+
{confirmed ? (
|
|
200
|
+
<div className="mt-6 rounded-xl border border-brand/30 bg-brand-soft/60 p-5 text-center">
|
|
201
|
+
<p className="text-[15px] font-semibold text-zinc-900">
|
|
202
|
+
{confirmed.serviceName} · {labelDow(confirmed.slot.startsAt)}{" "}
|
|
203
|
+
{labelDay(confirmed.slot.startsAt)} at {labelTime(confirmed.slot.startsAt)}
|
|
204
|
+
</p>
|
|
205
|
+
<p className="mt-2 text-[14px] text-zinc-600">
|
|
206
|
+
{siteConfig.booking.confirmationMessage}
|
|
207
|
+
</p>
|
|
208
|
+
</div>
|
|
209
|
+
) : selected && service ? (
|
|
210
|
+
/* …otherwise the form for the chosen slot. */
|
|
211
|
+
<BookingForm
|
|
212
|
+
service={service}
|
|
213
|
+
slot={selected}
|
|
214
|
+
onClear={() => setSelected(null)}
|
|
215
|
+
onBooked={() => {
|
|
216
|
+
setConfirmed({ slot: selected, serviceName: service.name });
|
|
217
|
+
setSelected(null);
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
) : null}
|
|
221
|
+
</div>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function BookingForm({
|
|
226
|
+
service,
|
|
227
|
+
slot,
|
|
228
|
+
onClear,
|
|
229
|
+
onBooked,
|
|
230
|
+
}: {
|
|
231
|
+
service: { slug: string; name: string; price: string };
|
|
232
|
+
slot: Slot;
|
|
233
|
+
onClear: () => void;
|
|
234
|
+
onBooked: () => void;
|
|
235
|
+
}) {
|
|
236
|
+
const [name, setName] = useState("");
|
|
237
|
+
const [email, setEmail] = useState("");
|
|
238
|
+
const [phone, setPhone] = useState("");
|
|
239
|
+
const [status, setStatus] = useState<"idle" | "booking">("idle");
|
|
240
|
+
const [error, setError] = useState<string | null>(null);
|
|
241
|
+
|
|
242
|
+
async function submit(e: React.FormEvent) {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
if (status === "booking") return;
|
|
245
|
+
if (!name.trim() || !email.trim()) {
|
|
246
|
+
setError("Name and email are required.");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
setStatus("booking");
|
|
250
|
+
setError(null);
|
|
251
|
+
try {
|
|
252
|
+
const res = await callFn<{ ok: boolean; reason?: string }>("createBooking", {
|
|
253
|
+
serviceSlug: service.slug,
|
|
254
|
+
startsAt: slot.startsAt,
|
|
255
|
+
customerName: name.trim(),
|
|
256
|
+
customerEmail: email.trim(),
|
|
257
|
+
customerPhone: phone.trim() || undefined,
|
|
258
|
+
});
|
|
259
|
+
if (res.ok) {
|
|
260
|
+
onBooked();
|
|
261
|
+
} else {
|
|
262
|
+
setStatus("idle");
|
|
263
|
+
setError(
|
|
264
|
+
res.reason === "taken"
|
|
265
|
+
? "Someone just grabbed that time — pick another."
|
|
266
|
+
: res.reason === "past"
|
|
267
|
+
? "That's too soon — choose a later time."
|
|
268
|
+
: "Couldn't book that slot. Try another.",
|
|
269
|
+
);
|
|
270
|
+
if (res.reason === "taken") onClear();
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
setStatus("idle");
|
|
274
|
+
setError(
|
|
275
|
+
/valid email|name/i.test(err instanceof Error ? err.message : "")
|
|
276
|
+
? "Check your name and email."
|
|
277
|
+
: "Something went wrong — try again.",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<form onSubmit={submit} className="mt-6 rounded-xl border border-zinc-200 bg-paper p-5">
|
|
284
|
+
<div className="flex items-center justify-between">
|
|
285
|
+
<div className="text-[14px] font-medium text-zinc-900">
|
|
286
|
+
{service.name} · {labelDow(slot.startsAt)} {labelDay(slot.startsAt)} at{" "}
|
|
287
|
+
{labelTime(slot.startsAt)}
|
|
288
|
+
</div>
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
onClick={onClear}
|
|
292
|
+
className="text-[13px] text-zinc-400 transition-colors hover:text-zinc-700"
|
|
293
|
+
>
|
|
294
|
+
Change
|
|
295
|
+
</button>
|
|
296
|
+
</div>
|
|
297
|
+
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
298
|
+
<input
|
|
299
|
+
value={name}
|
|
300
|
+
onChange={(e) => setName(e.target.value)}
|
|
301
|
+
placeholder="Your name"
|
|
302
|
+
aria-label="Your name"
|
|
303
|
+
autoComplete="name"
|
|
304
|
+
className={inputCls}
|
|
305
|
+
/>
|
|
306
|
+
<input
|
|
307
|
+
type="email"
|
|
308
|
+
value={email}
|
|
309
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
310
|
+
placeholder="you@email.com"
|
|
311
|
+
aria-label="Email"
|
|
312
|
+
autoComplete="email"
|
|
313
|
+
className={inputCls}
|
|
314
|
+
/>
|
|
315
|
+
<input
|
|
316
|
+
type="tel"
|
|
317
|
+
value={phone}
|
|
318
|
+
onChange={(e) => setPhone(e.target.value)}
|
|
319
|
+
placeholder="Phone (optional)"
|
|
320
|
+
aria-label="Phone"
|
|
321
|
+
autoComplete="tel"
|
|
322
|
+
className={inputCls + " sm:col-span-2"}
|
|
323
|
+
/>
|
|
324
|
+
</div>
|
|
325
|
+
{error ? <p className="mt-3 text-[13px] text-red-600">{error}</p> : null}
|
|
326
|
+
<button
|
|
327
|
+
type="submit"
|
|
328
|
+
disabled={status === "booking"}
|
|
329
|
+
className="mt-4 inline-flex h-10 w-full items-center justify-center rounded-lg bg-brand text-sm font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60"
|
|
330
|
+
>
|
|
331
|
+
{status === "booking" ? "Booking…" : "Confirm booking"}
|
|
332
|
+
</button>
|
|
333
|
+
<p className="mt-2 text-center text-[12px] text-zinc-400">
|
|
334
|
+
No payment now — pay at the shop.
|
|
335
|
+
</p>
|
|
336
|
+
</form>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const inputCls =
|
|
341
|
+
"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";
|
|
342
|
+
|
|
343
|
+
/* ------------------------------ labels -------------------------------- */
|
|
344
|
+
|
|
345
|
+
// For full ISO INSTANTS (a slot's startsAt) — `new Date(iso)` is the right
|
|
346
|
+
// instant, shown in the viewer's local time.
|
|
347
|
+
function labelDow(iso: string) {
|
|
348
|
+
return new Date(iso).toLocaleDateString(undefined, { weekday: "short" });
|
|
349
|
+
}
|
|
350
|
+
function labelDay(iso: string) {
|
|
351
|
+
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
352
|
+
}
|
|
353
|
+
function labelTime(iso: string) {
|
|
354
|
+
return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// For a local DAY KEY ("YYYY-MM-DD") — parse the parts LOCALLY. `new Date("2026-06-15")`
|
|
358
|
+
// would parse as UTC midnight and shift back a day in negative-UTC zones, so
|
|
359
|
+
// build the date from its parts instead.
|
|
360
|
+
function keyToLocalDate(key: string): Date {
|
|
361
|
+
const [y, m, d] = key.split("-").map(Number);
|
|
362
|
+
return new Date(y, m - 1, d);
|
|
363
|
+
}
|
|
364
|
+
function dowOfKey(key: string) {
|
|
365
|
+
return keyToLocalDate(key).toLocaleDateString(undefined, { weekday: "short" });
|
|
366
|
+
}
|
|
367
|
+
function dayOfKey(key: string) {
|
|
368
|
+
return keyToLocalDate(key).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* ----------------------------- skeletons ------------------------------ */
|
|
372
|
+
|
|
373
|
+
function PickerSkeleton() {
|
|
374
|
+
return (
|
|
375
|
+
<div className="rounded-2xl border border-zinc-200 bg-white p-7">
|
|
376
|
+
<div className="flex gap-2">
|
|
377
|
+
{[0, 1, 2, 3].map((i) => (
|
|
378
|
+
<div key={i} className="h-8 w-24 animate-pulse rounded-full bg-zinc-100" />
|
|
379
|
+
))}
|
|
380
|
+
</div>
|
|
381
|
+
<div className="mt-5 flex gap-2">
|
|
382
|
+
{[0, 1, 2, 3, 4].map((i) => (
|
|
383
|
+
<div key={i} className="h-14 w-16 animate-pulse rounded-xl bg-zinc-100" />
|
|
384
|
+
))}
|
|
385
|
+
</div>
|
|
386
|
+
<SlotsSkeleton />
|
|
387
|
+
</div>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function SlotsSkeleton() {
|
|
392
|
+
return (
|
|
393
|
+
<div className="mt-5 grid grid-cols-3 gap-2 sm:grid-cols-4">
|
|
394
|
+
{Array.from({ length: 8 }).map((_, i) => (
|
|
395
|
+
<div key={i} className="h-9 animate-pulse rounded-lg bg-zinc-100" />
|
|
396
|
+
))}
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
}
|