@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,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 slot counter), and without
|
|
17
|
+
// persisting the real session that stale guest token would shadow the owner's
|
|
18
|
+
// — so the owner-only `inquiriesForOwner` call would come back as a guest and
|
|
19
|
+
// get 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 studio is single-tenant: there's no public sign-up, just the owner creating
|
|
23
|
+
// their one account. Whoever signs in only sees data if their email matches
|
|
24
|
+
// PYLON_OWNER_EMAIL — enforced by the inquiriesForOwner function.
|
|
25
|
+
export function AuthForm() {
|
|
26
|
+
const [mode, setMode] = useState<"login" | "register">("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" ? "register" : "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,258 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { db, callFn } from "@pylonsync/react";
|
|
5
|
+
import { EnsureGuest } from "@pylonsync/client";
|
|
6
|
+
import { siteConfig } from "@/lib/site.config";
|
|
7
|
+
|
|
8
|
+
// The realtime pieces of the page, both driven by the public, PII-free Capacity
|
|
9
|
+
// row via `db.useQuery("Capacity")` — so when the owner books a project from the
|
|
10
|
+
// dashboard, the "N slots open" number drops live in every open tab. No refresh.
|
|
11
|
+
//
|
|
12
|
+
// • <LiveSlots> — the hero pill ("3 project slots open · Q3 2026").
|
|
13
|
+
// • <ContactForm> — the "start a project" form. submitInquiry is a public
|
|
14
|
+
// mutation, so it works for anonymous visitors; the Inquiry
|
|
15
|
+
// it writes is pure PII and can never be read back by a
|
|
16
|
+
// client. The form only ever reads the slot count.
|
|
17
|
+
//
|
|
18
|
+
// Both are wrapped in <EnsureGuest>, which mints an anonymous guest session so
|
|
19
|
+
// the sync connection (which powers the live count) is established. That session
|
|
20
|
+
// holds no PII and can't read the Inquiry table.
|
|
21
|
+
|
|
22
|
+
interface CapacityRow {
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
openSlots: number;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function useSeedCapacity() {
|
|
30
|
+
// Create the Capacity row from config on first visit (idempotent server-side).
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
void callFn("seedCapacity", {});
|
|
33
|
+
}, []);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* ------------------------------ hero pill ------------------------------ */
|
|
37
|
+
|
|
38
|
+
export function LiveSlots() {
|
|
39
|
+
return (
|
|
40
|
+
<EnsureGuest fallback={<SlotsPill loading />}>
|
|
41
|
+
<LiveSlotsInner />
|
|
42
|
+
</EnsureGuest>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function LiveSlotsInner() {
|
|
47
|
+
useSeedCapacity();
|
|
48
|
+
const { data, loading } = db.useQuery<CapacityRow>("Capacity");
|
|
49
|
+
const row = data[0];
|
|
50
|
+
return <SlotsPill openSlots={row?.openSlots} label={row?.label} live={!loading} />;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function SlotsPill({
|
|
54
|
+
openSlots,
|
|
55
|
+
label,
|
|
56
|
+
live,
|
|
57
|
+
loading,
|
|
58
|
+
}: {
|
|
59
|
+
openSlots?: number;
|
|
60
|
+
label?: string;
|
|
61
|
+
live?: boolean;
|
|
62
|
+
loading?: boolean;
|
|
63
|
+
}) {
|
|
64
|
+
const open = openSlots ?? siteConfig.capacity.openSlots;
|
|
65
|
+
const period = label || siteConfig.capacity.label;
|
|
66
|
+
const bookedOut = open <= 0;
|
|
67
|
+
return (
|
|
68
|
+
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-3.5 py-1.5 text-[13px] font-medium text-zinc-700 shadow-sm">
|
|
69
|
+
{loading ? (
|
|
70
|
+
<span className="inline-flex size-2 rounded-full bg-zinc-300" />
|
|
71
|
+
) : (
|
|
72
|
+
<span className="relative flex size-2">
|
|
73
|
+
{live && !bookedOut ? (
|
|
74
|
+
<span className="absolute inline-flex size-2 animate-ping rounded-full bg-green-500/60" />
|
|
75
|
+
) : null}
|
|
76
|
+
<span
|
|
77
|
+
className={"relative inline-flex size-2 rounded-full " + (bookedOut ? "bg-zinc-400" : "bg-green-600")}
|
|
78
|
+
/>
|
|
79
|
+
</span>
|
|
80
|
+
)}
|
|
81
|
+
{bookedOut ? (
|
|
82
|
+
<span>Booked through {period}</span>
|
|
83
|
+
) : (
|
|
84
|
+
<span>
|
|
85
|
+
<span className="tabular-nums text-zinc-900">{open}</span>{" "}
|
|
86
|
+
{open === 1 ? "project slot" : "project slots"} open · {period}
|
|
87
|
+
</span>
|
|
88
|
+
)}
|
|
89
|
+
</span>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ----------------------------- contact form ---------------------------- */
|
|
94
|
+
|
|
95
|
+
export function ContactForm() {
|
|
96
|
+
return (
|
|
97
|
+
<EnsureGuest fallback={<FormShell disabled />}>
|
|
98
|
+
<ContactFormInner />
|
|
99
|
+
</EnsureGuest>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function ContactFormInner() {
|
|
104
|
+
useSeedCapacity();
|
|
105
|
+
const { data } = db.useQuery<CapacityRow>("Capacity");
|
|
106
|
+
const row = data[0];
|
|
107
|
+
const bookedOut = (row?.openSlots ?? siteConfig.capacity.openSlots) <= 0;
|
|
108
|
+
return <FormShell bookedOut={bookedOut} period={row?.label} />;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function FormShell({
|
|
112
|
+
disabled,
|
|
113
|
+
bookedOut,
|
|
114
|
+
period,
|
|
115
|
+
}: {
|
|
116
|
+
disabled?: boolean;
|
|
117
|
+
bookedOut?: boolean;
|
|
118
|
+
period?: string;
|
|
119
|
+
}) {
|
|
120
|
+
const { contact } = siteConfig;
|
|
121
|
+
const [form, setForm] = useState({
|
|
122
|
+
name: "",
|
|
123
|
+
email: "",
|
|
124
|
+
company: "",
|
|
125
|
+
projectType: "",
|
|
126
|
+
budget: "",
|
|
127
|
+
message: "",
|
|
128
|
+
});
|
|
129
|
+
const [status, setStatus] = useState<"idle" | "sending" | "done">("idle");
|
|
130
|
+
const [error, setError] = useState<string | null>(null);
|
|
131
|
+
|
|
132
|
+
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
|
|
133
|
+
setForm((f) => ({ ...f, [k]: e.target.value }));
|
|
134
|
+
|
|
135
|
+
async function onSubmit(e: React.FormEvent) {
|
|
136
|
+
e.preventDefault();
|
|
137
|
+
if (status === "sending") return;
|
|
138
|
+
if (!form.name.trim() || !form.email.trim()) {
|
|
139
|
+
setError("Your name and email are required.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
setStatus("sending");
|
|
143
|
+
setError(null);
|
|
144
|
+
try {
|
|
145
|
+
await callFn<{ ok: boolean }>("submitInquiry", {
|
|
146
|
+
name: form.name.trim(),
|
|
147
|
+
email: form.email.trim(),
|
|
148
|
+
company: form.company.trim() || undefined,
|
|
149
|
+
projectType: form.projectType || undefined,
|
|
150
|
+
budget: form.budget || undefined,
|
|
151
|
+
message: form.message.trim() || undefined,
|
|
152
|
+
});
|
|
153
|
+
setStatus("done");
|
|
154
|
+
} catch (err) {
|
|
155
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
156
|
+
setError(
|
|
157
|
+
/valid email|INVALID_ARGS/i.test(msg)
|
|
158
|
+
? "Please enter a valid email address."
|
|
159
|
+
: "Something went wrong — try again in a moment.",
|
|
160
|
+
);
|
|
161
|
+
setStatus("idle");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (status === "done") {
|
|
166
|
+
return (
|
|
167
|
+
<div className="rounded-2xl border border-brand/30 bg-brand-soft/50 px-6 py-8 text-center">
|
|
168
|
+
<div className="mx-auto flex size-10 items-center justify-center rounded-full bg-brand text-white">
|
|
169
|
+
<CheckIcon />
|
|
170
|
+
</div>
|
|
171
|
+
<p className="mt-3 text-[15px] font-semibold text-zinc-900">{contact.confirmationMessage}</p>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<form onSubmit={onSubmit} className="rounded-2xl border border-zinc-200 bg-white p-6 shadow-sm sm:p-7">
|
|
178
|
+
{bookedOut ? (
|
|
179
|
+
<p className="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-[13px] text-amber-700">
|
|
180
|
+
We're fully booked{period ? ` through ${period}` : ""} — send a note anyway and we'll reach out when a slot opens.
|
|
181
|
+
</p>
|
|
182
|
+
) : null}
|
|
183
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
184
|
+
<Field label="Name" required>
|
|
185
|
+
<input value={form.name} onChange={set("name")} autoComplete="name" aria-label="Name" className={inputCls} disabled={disabled} />
|
|
186
|
+
</Field>
|
|
187
|
+
<Field label="Email" required>
|
|
188
|
+
<input type="email" value={form.email} onChange={set("email")} autoComplete="email" aria-label="Email" className={inputCls} disabled={disabled} />
|
|
189
|
+
</Field>
|
|
190
|
+
<Field label="Company">
|
|
191
|
+
<input value={form.company} onChange={set("company")} autoComplete="organization" aria-label="Company" className={inputCls} disabled={disabled} />
|
|
192
|
+
</Field>
|
|
193
|
+
<Field label="Project type">
|
|
194
|
+
<select value={form.projectType} onChange={set("projectType")} aria-label="Project type" className={inputCls} disabled={disabled}>
|
|
195
|
+
<option value="">Select…</option>
|
|
196
|
+
{contact.projectTypes.map((t) => (
|
|
197
|
+
<option key={t} value={t}>{t}</option>
|
|
198
|
+
))}
|
|
199
|
+
</select>
|
|
200
|
+
</Field>
|
|
201
|
+
</div>
|
|
202
|
+
<div className="mt-3">
|
|
203
|
+
<Field label="Budget">
|
|
204
|
+
<select value={form.budget} onChange={set("budget")} aria-label="Budget" className={inputCls} disabled={disabled}>
|
|
205
|
+
<option value="">Select…</option>
|
|
206
|
+
{contact.budgets.map((b) => (
|
|
207
|
+
<option key={b} value={b}>{b}</option>
|
|
208
|
+
))}
|
|
209
|
+
</select>
|
|
210
|
+
</Field>
|
|
211
|
+
</div>
|
|
212
|
+
<div className="mt-3">
|
|
213
|
+
<Field label="What are you building?">
|
|
214
|
+
<textarea
|
|
215
|
+
value={form.message}
|
|
216
|
+
onChange={set("message")}
|
|
217
|
+
rows={4}
|
|
218
|
+
aria-label="Message"
|
|
219
|
+
placeholder="A sentence or two about the project, timeline, and what success looks like."
|
|
220
|
+
className={inputCls + " resize-none py-2.5"}
|
|
221
|
+
disabled={disabled}
|
|
222
|
+
/>
|
|
223
|
+
</Field>
|
|
224
|
+
</div>
|
|
225
|
+
{error ? <p className="mt-3 text-[13px] text-red-600">{error}</p> : null}
|
|
226
|
+
<button
|
|
227
|
+
type="submit"
|
|
228
|
+
disabled={status === "sending" || disabled}
|
|
229
|
+
className="mt-5 inline-flex h-11 w-full items-center justify-center rounded-full bg-brand text-[15px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-60 sm:w-auto sm:px-7"
|
|
230
|
+
>
|
|
231
|
+
{status === "sending" ? "Sending…" : "Send inquiry"}
|
|
232
|
+
</button>
|
|
233
|
+
</form>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
|
|
238
|
+
return (
|
|
239
|
+
<label className="block">
|
|
240
|
+
<span className="mb-1.5 block text-[12.5px] font-medium text-zinc-600">
|
|
241
|
+
{label}
|
|
242
|
+
{required ? <span className="text-brand"> *</span> : null}
|
|
243
|
+
</span>
|
|
244
|
+
{children}
|
|
245
|
+
</label>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const inputCls =
|
|
250
|
+
"h-10 w-full rounded-lg border border-zinc-300 bg-white px-3 text-[14px] text-zinc-900 outline-none transition placeholder:text-zinc-400 focus:border-brand focus:ring-2 focus:ring-brand/20 disabled:opacity-60";
|
|
251
|
+
|
|
252
|
+
function CheckIcon() {
|
|
253
|
+
return (
|
|
254
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
255
|
+
<path d="M20 6 9 17l-5-5" />
|
|
256
|
+
</svg>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState } from "react";
|
|
4
|
+
import { db, callFn } from "@pylonsync/react";
|
|
5
|
+
import { useAuth } from "@pylonsync/client";
|
|
6
|
+
import type { InquiryRow, OwnerInquiriesResult } from "@/lib/agency";
|
|
7
|
+
|
|
8
|
+
interface CapacityRow {
|
|
9
|
+
id: string;
|
|
10
|
+
label: string;
|
|
11
|
+
openSlots: number;
|
|
12
|
+
updatedAt: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The owner's live pipeline. Liveness rides the SAME public Capacity row the
|
|
16
|
+
// landing page uses: `db.useQuery("Capacity")` re-renders the instant slots
|
|
17
|
+
// change (cross-tab, via the replica). The leads themselves never sync — they
|
|
18
|
+
// come from the owner-gated `inquiriesForOwner`, (re)fetched on mount and
|
|
19
|
+
// whenever capacity changes (which is exactly when a lead is booked/released).
|
|
20
|
+
// So the pipeline stays live, but PII only ever travels through the gated call.
|
|
21
|
+
export function AgencyDashboard({ userEmail }: { userEmail: string }) {
|
|
22
|
+
const { data: caps } = db.useQuery<CapacityRow>("Capacity");
|
|
23
|
+
const cap = caps[0];
|
|
24
|
+
const liveKey = `${cap?.openSlots ?? "?"}:${cap?.label ?? ""}`;
|
|
25
|
+
|
|
26
|
+
const [inquiries, setInquiries] = useState<InquiryRow[] | null>(null);
|
|
27
|
+
const [denied, setDenied] = useState(false);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const [busyId, setBusyId] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
async function load() {
|
|
32
|
+
try {
|
|
33
|
+
const r = await callFn<OwnerInquiriesResult>("inquiriesForOwner", {});
|
|
34
|
+
if (!r.authorized) setDenied(true);
|
|
35
|
+
else {
|
|
36
|
+
setInquiries(r.inquiries);
|
|
37
|
+
setDenied(false);
|
|
38
|
+
setError(null);
|
|
39
|
+
}
|
|
40
|
+
} catch (e) {
|
|
41
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
void load();
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, [liveKey]);
|
|
49
|
+
|
|
50
|
+
async function act(id: string, fn: "bookInquiry" | "declineInquiry") {
|
|
51
|
+
setBusyId(id);
|
|
52
|
+
try {
|
|
53
|
+
await callFn(fn, { inquiryId: id });
|
|
54
|
+
await load();
|
|
55
|
+
} finally {
|
|
56
|
+
setBusyId(null);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (denied) return <OwnerOnly email={userEmail} />;
|
|
61
|
+
if (error) {
|
|
62
|
+
return <div className="rounded-xl border border-red-200 bg-red-50 px-5 py-4 text-sm text-red-700">{error}</div>;
|
|
63
|
+
}
|
|
64
|
+
if (!inquiries) return <Skeleton />;
|
|
65
|
+
|
|
66
|
+
const newCount = inquiries.filter((i) => i.status === "new").length;
|
|
67
|
+
const bookedCount = inquiries.filter((i) => i.status === "booked").length;
|
|
68
|
+
const active = inquiries.filter((i) => i.status !== "declined");
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="space-y-8">
|
|
72
|
+
<div>
|
|
73
|
+
<h1 className="text-xl font-semibold tracking-tight">Pipeline</h1>
|
|
74
|
+
<p className="mt-1 text-sm text-zinc-500">
|
|
75
|
+
Live — leads land here the moment they're sent. Booking one drops the open-slot count
|
|
76
|
+
on your site instantly.
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
81
|
+
<Stat label="Open slots" value={String(cap?.openSlots ?? 0)} hint={cap?.label} />
|
|
82
|
+
<Stat label="New leads" value={String(newCount)} />
|
|
83
|
+
<Stat label="Booked" value={String(bookedCount)} />
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<CapacityCard cap={cap} />
|
|
87
|
+
|
|
88
|
+
{/* Inquiries */}
|
|
89
|
+
<div className="rounded-xl border border-zinc-200 bg-white">
|
|
90
|
+
<div className="border-b border-zinc-100 px-4 py-3 text-sm font-semibold text-zinc-900">
|
|
91
|
+
Inquiries <span className="font-normal text-zinc-400">({active.length})</span>
|
|
92
|
+
</div>
|
|
93
|
+
{inquiries.length === 0 ? (
|
|
94
|
+
<p className="p-8 text-center text-sm text-zinc-500">No inquiries yet — share your site.</p>
|
|
95
|
+
) : (
|
|
96
|
+
<ul className="divide-y divide-zinc-100">
|
|
97
|
+
{inquiries.map((i) => (
|
|
98
|
+
<li key={i.id} className="px-4 py-3.5">
|
|
99
|
+
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
|
|
100
|
+
<div className="min-w-0 flex-1">
|
|
101
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
102
|
+
<span className="text-[14px] font-medium text-zinc-900">{i.name}</span>
|
|
103
|
+
<StatusBadge status={i.status} />
|
|
104
|
+
</div>
|
|
105
|
+
<div className="mt-0.5 truncate text-[12.5px] text-zinc-500">
|
|
106
|
+
{i.email}
|
|
107
|
+
{i.company ? ` · ${i.company}` : ""}
|
|
108
|
+
{i.projectType ? ` · ${i.projectType}` : ""}
|
|
109
|
+
{i.budget ? ` · ${i.budget}` : ""}
|
|
110
|
+
</div>
|
|
111
|
+
{i.message ? (
|
|
112
|
+
<p className="mt-1.5 line-clamp-2 text-[13px] leading-relaxed text-zinc-600">{i.message}</p>
|
|
113
|
+
) : null}
|
|
114
|
+
</div>
|
|
115
|
+
<div className="flex items-center gap-2">
|
|
116
|
+
{i.status !== "booked" ? (
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
disabled={busyId === i.id}
|
|
120
|
+
onClick={() => act(i.id, "bookInquiry")}
|
|
121
|
+
className="rounded-md bg-brand px-3 py-1.5 text-[12.5px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
122
|
+
>
|
|
123
|
+
{busyId === i.id ? "…" : "Book"}
|
|
124
|
+
</button>
|
|
125
|
+
) : null}
|
|
126
|
+
{i.status !== "declined" ? (
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
disabled={busyId === i.id}
|
|
130
|
+
onClick={() => act(i.id, "declineInquiry")}
|
|
131
|
+
className="rounded-md border border-zinc-300 px-3 py-1.5 text-[12.5px] font-medium text-zinc-600 transition-colors hover:border-red-300 hover:text-red-600 disabled:opacity-50"
|
|
132
|
+
>
|
|
133
|
+
{i.status === "booked" ? "Release" : "Decline"}
|
|
134
|
+
</button>
|
|
135
|
+
) : null}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</li>
|
|
139
|
+
))}
|
|
140
|
+
</ul>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Editable capacity — the number the public hero shows live. Saving calls
|
|
148
|
+
// setCapacity; the change syncs straight to every open landing page.
|
|
149
|
+
function CapacityCard({ cap }: { cap?: CapacityRow }) {
|
|
150
|
+
const [label, setLabel] = useState(cap?.label ?? "");
|
|
151
|
+
const [slots, setSlots] = useState(String(cap?.openSlots ?? 0));
|
|
152
|
+
const [saving, setSaving] = useState(false);
|
|
153
|
+
const [saved, setSaved] = useState(false);
|
|
154
|
+
|
|
155
|
+
// Keep inputs in sync if the live row changes underneath us (e.g. a booking).
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
setLabel(cap?.label ?? "");
|
|
158
|
+
setSlots(String(cap?.openSlots ?? 0));
|
|
159
|
+
}, [cap?.label, cap?.openSlots]);
|
|
160
|
+
|
|
161
|
+
async function save(e: React.FormEvent) {
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
setSaving(true);
|
|
164
|
+
setSaved(false);
|
|
165
|
+
try {
|
|
166
|
+
await callFn("setCapacity", { label: label.trim(), openSlots: Math.max(0, parseInt(slots, 10) || 0) });
|
|
167
|
+
setSaved(true);
|
|
168
|
+
} finally {
|
|
169
|
+
setSaving(false);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<form onSubmit={save} className="rounded-xl border border-zinc-200 bg-white p-4">
|
|
175
|
+
<div className="text-sm font-semibold text-zinc-900">Availability</div>
|
|
176
|
+
<p className="mt-1 text-[13px] text-zinc-500">Shown live on your site as “N project slots open”.</p>
|
|
177
|
+
<div className="mt-4 flex flex-wrap items-end gap-3">
|
|
178
|
+
<label className="block">
|
|
179
|
+
<span className="mb-1 block text-[12px] font-medium text-zinc-600">Booking window</span>
|
|
180
|
+
<input
|
|
181
|
+
value={label}
|
|
182
|
+
onChange={(e) => { setLabel(e.target.value); setSaved(false); }}
|
|
183
|
+
placeholder="Q3 2026"
|
|
184
|
+
className="h-9 w-40 rounded-lg border border-zinc-300 px-3 text-[14px] outline-none focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
185
|
+
/>
|
|
186
|
+
</label>
|
|
187
|
+
<label className="block">
|
|
188
|
+
<span className="mb-1 block text-[12px] font-medium text-zinc-600">Open slots</span>
|
|
189
|
+
<input
|
|
190
|
+
type="number"
|
|
191
|
+
min={0}
|
|
192
|
+
value={slots}
|
|
193
|
+
onChange={(e) => { setSlots(e.target.value); setSaved(false); }}
|
|
194
|
+
className="h-9 w-24 rounded-lg border border-zinc-300 px-3 text-[14px] tabular-nums outline-none focus:border-brand focus:ring-2 focus:ring-brand/20"
|
|
195
|
+
/>
|
|
196
|
+
</label>
|
|
197
|
+
<button
|
|
198
|
+
type="submit"
|
|
199
|
+
disabled={saving}
|
|
200
|
+
className="h-9 rounded-lg bg-zinc-900 px-4 text-[13px] font-medium text-white transition-colors hover:bg-zinc-700 disabled:opacity-50"
|
|
201
|
+
>
|
|
202
|
+
{saving ? "Saving…" : saved ? "Saved ✓" : "Save"}
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
205
|
+
</form>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function Stat({ label, value, hint }: { label: string; value: string; hint?: string }) {
|
|
210
|
+
return (
|
|
211
|
+
<div className="rounded-xl border border-zinc-200 bg-white p-4">
|
|
212
|
+
<div className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">{label}</div>
|
|
213
|
+
<div className="mt-1 text-2xl font-semibold tabular-nums text-zinc-900">{value}</div>
|
|
214
|
+
{hint ? <div className="mt-0.5 text-[12px] text-zinc-400">{hint}</div> : null}
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function StatusBadge({ status }: { status: string }) {
|
|
220
|
+
const tone =
|
|
221
|
+
status === "booked"
|
|
222
|
+
? "bg-green-50 text-green-700"
|
|
223
|
+
: status === "declined"
|
|
224
|
+
? "bg-zinc-100 text-zinc-400"
|
|
225
|
+
: "bg-amber-50 text-amber-700"; // new
|
|
226
|
+
const label = status === "new" ? "new lead" : status;
|
|
227
|
+
return <span className={"rounded-full px-2 py-0.5 text-[10px] font-medium capitalize " + tone}>{label}</span>;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function OwnerOnly({ email }: { email: string }) {
|
|
231
|
+
return (
|
|
232
|
+
<div className="rounded-xl border border-dashed border-zinc-300 px-6 py-12 text-center">
|
|
233
|
+
<h1 className="text-lg font-semibold">This dashboard is owner-only</h1>
|
|
234
|
+
<p className="mx-auto mt-2 max-w-md text-sm text-zinc-500">
|
|
235
|
+
You're signed in as <span className="font-medium text-zinc-700">{email || "this account"}</span>.
|
|
236
|
+
Only the studio owner can see inquiries. Set{" "}
|
|
237
|
+
<code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">PYLON_OWNER_EMAIL={email || "you@studio.com"}</code>{" "}
|
|
238
|
+
in your <code className="rounded bg-zinc-100 px-1.5 py-0.5 text-[12px]">.env</code>, restart, and reload —
|
|
239
|
+
or sign in with the owner account.
|
|
240
|
+
</p>
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function UserMenu({ email }: { email: string }) {
|
|
246
|
+
const { signOut } = useAuth();
|
|
247
|
+
const initial = (email.trim()[0] || "?").toUpperCase();
|
|
248
|
+
async function onSignOut() {
|
|
249
|
+
await signOut();
|
|
250
|
+
window.location.assign("/");
|
|
251
|
+
}
|
|
252
|
+
return (
|
|
253
|
+
<details className="group relative">
|
|
254
|
+
<summary className="flex size-8 cursor-pointer select-none list-none items-center justify-center rounded-full bg-zinc-900 text-[12px] font-semibold text-white marker:hidden [&::-webkit-details-marker]:hidden">
|
|
255
|
+
{initial}
|
|
256
|
+
</summary>
|
|
257
|
+
<div className="absolute right-0 top-full z-40 mt-2 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.25)]">
|
|
258
|
+
<div className="border-b border-zinc-100 px-3 py-2">
|
|
259
|
+
<div className="truncate text-[13px] font-medium text-zinc-900">{email || "Signed in"}</div>
|
|
260
|
+
</div>
|
|
261
|
+
<button
|
|
262
|
+
type="button"
|
|
263
|
+
onClick={onSignOut}
|
|
264
|
+
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
|
|
265
|
+
>
|
|
266
|
+
Sign out
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
</details>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function Skeleton() {
|
|
274
|
+
return (
|
|
275
|
+
<div className="space-y-8">
|
|
276
|
+
<div className="h-6 w-28 animate-pulse rounded bg-zinc-100" />
|
|
277
|
+
<div className="grid gap-4 sm:grid-cols-3">
|
|
278
|
+
{[0, 1, 2].map((i) => (
|
|
279
|
+
<div key={i} className="h-20 animate-pulse rounded-xl bg-zinc-100" />
|
|
280
|
+
))}
|
|
281
|
+
</div>
|
|
282
|
+
<div className="h-28 animate-pulse rounded-xl bg-zinc-100" />
|
|
283
|
+
<div className="h-48 animate-pulse rounded-xl bg-zinc-100" />
|
|
284
|
+
</div>
|
|
285
|
+
);
|
|
286
|
+
}
|