@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,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,30 @@
|
|
|
1
|
+
import { query } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import type { BookingRow, OwnerBookingsResult } from "../lib/booking";
|
|
4
|
+
|
|
5
|
+
// bookingsForOwner — the owner's view of every appointment, INCLUDING the
|
|
6
|
+
// customer name/email/phone. This is the one function allowed to return that
|
|
7
|
+
// PII, so it's gated to the configured owner (PYLON_OWNER_EMAIL via ctx.env).
|
|
8
|
+
//
|
|
9
|
+
// The dashboard calls it with `callFn` and re-fetches whenever the live, public
|
|
10
|
+
// BookedSlot set changes — so new bookings + cancellations show up without a
|
|
11
|
+
// refresh, while the contact details themselves never travel over entity sync.
|
|
12
|
+
export default query({
|
|
13
|
+
auth: "user",
|
|
14
|
+
async handler(ctx): Promise<OwnerBookingsResult> {
|
|
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
|
+
// Booking denies all client reads; the owner-only full list goes through
|
|
22
|
+
// the intentional cross-user read surface. Chronological by start.
|
|
23
|
+
const rows = (await ctx.db.unsafe.list("Booking")) as unknown as BookingRow[];
|
|
24
|
+
const bookings = rows
|
|
25
|
+
.map((r) => ({ ...r }))
|
|
26
|
+
.sort((a, b) => (a.startsAt < b.startsAt ? -1 : a.startsAt > b.startsAt ? 1 : 0));
|
|
27
|
+
|
|
28
|
+
return { authorized: true, bookings };
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// cancelBooking — owner-only. Marks the booking cancelled AND deletes its
|
|
5
|
+
// BookedSlot projection, which FREES the time: the deletion syncs to every open
|
|
6
|
+
// picker, so the slot becomes bookable again live.
|
|
7
|
+
export default mutation<{ bookingId: string }, { ok: boolean }>({
|
|
8
|
+
auth: "user",
|
|
9
|
+
args: { bookingId: v.id("Booking") },
|
|
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 bookings.");
|
|
14
|
+
}
|
|
15
|
+
await ctx.db.unsafe.update("Booking", args.bookingId, { status: "cancelled" });
|
|
16
|
+
|
|
17
|
+
// Free the slot — find the projection row pointing at this booking.
|
|
18
|
+
const slots = (await ctx.db.unsafe.list("BookedSlot")) as unknown as {
|
|
19
|
+
id: string;
|
|
20
|
+
bookingId: string;
|
|
21
|
+
}[];
|
|
22
|
+
const slot = slots.find((s) => s.bookingId === args.bookingId);
|
|
23
|
+
if (slot) await ctx.db.unsafe.delete("BookedSlot", slot.id);
|
|
24
|
+
|
|
25
|
+
return { ok: true };
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// confirmBooking — owner-only. Marks a pending booking confirmed. The BookedSlot
|
|
5
|
+
// already exists (createBooking wrote it), so the time stays held. Mutations DO
|
|
6
|
+
// have `ctx.error`, so the non-owner deny throws a typed error here.
|
|
7
|
+
export default mutation<{ bookingId: string }, { ok: boolean }>({
|
|
8
|
+
auth: "user",
|
|
9
|
+
args: { bookingId: v.id("Booking") },
|
|
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 bookings.");
|
|
14
|
+
}
|
|
15
|
+
await ctx.db.unsafe.update("Booking", args.bookingId, { status: "confirmed" });
|
|
16
|
+
return { ok: true };
|
|
17
|
+
},
|
|
18
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { siteConfig } from "../lib/site.config";
|
|
3
|
+
import { rangesOverlap } from "../lib/slots";
|
|
4
|
+
|
|
5
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
|
+
|
|
7
|
+
// createBooking — the ONLY way a Booking + its BookedSlot projection are
|
|
8
|
+
// written. It's a `mutation` (transactional, has `ctx.db`): one atomic write of
|
|
9
|
+
// the PII Booking row plus the PII-free BookedSlot, and the BookedSlot insert
|
|
10
|
+
// fires a change event that greys the slot out on every open picker in
|
|
11
|
+
// realtime.
|
|
12
|
+
//
|
|
13
|
+
// `auth: "public"` — a visitor has no account. The server RE-CHECKS that the
|
|
14
|
+
// slot is still free before inserting (the live UI mostly prevents collisions,
|
|
15
|
+
// but two people can hit "book" within the same instant). A per-day advisory
|
|
16
|
+
// lock serializes the check-then-insert so the re-check can't race — including
|
|
17
|
+
// on Postgres, where mutations can otherwise interleave.
|
|
18
|
+
//
|
|
19
|
+
// PRIVACY: returns only `{ ok, reason? }` — never a booking row or anyone's
|
|
20
|
+
// contact details.
|
|
21
|
+
export default mutation<
|
|
22
|
+
{
|
|
23
|
+
serviceSlug: string;
|
|
24
|
+
startsAt: string;
|
|
25
|
+
customerName: string;
|
|
26
|
+
customerEmail: string;
|
|
27
|
+
customerPhone?: string;
|
|
28
|
+
},
|
|
29
|
+
{ ok: boolean; reason?: "past" | "taken" | "invalid" }
|
|
30
|
+
>({
|
|
31
|
+
auth: "public",
|
|
32
|
+
args: {
|
|
33
|
+
serviceSlug: v.string(),
|
|
34
|
+
startsAt: v.string(),
|
|
35
|
+
customerName: v.string(),
|
|
36
|
+
customerEmail: v.string(),
|
|
37
|
+
customerPhone: v.optional(v.string()),
|
|
38
|
+
},
|
|
39
|
+
async handler(ctx, args) {
|
|
40
|
+
const svc = siteConfig.services.items.find((s) => s.slug === args.serviceSlug);
|
|
41
|
+
if (!svc) throw ctx.error("INVALID_ARGS", "Unknown service.");
|
|
42
|
+
|
|
43
|
+
const name = args.customerName.trim();
|
|
44
|
+
const email = args.customerEmail.trim().toLowerCase();
|
|
45
|
+
const phone = args.customerPhone?.trim() || null;
|
|
46
|
+
if (name.length < 1 || name.length > 120) {
|
|
47
|
+
throw ctx.error("INVALID_ARGS", "Enter your name.");
|
|
48
|
+
}
|
|
49
|
+
if (!EMAIL_RE.test(email) || email.length > 254) {
|
|
50
|
+
throw ctx.error("INVALID_ARGS", "Enter a valid email address.");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const startMs = Date.parse(args.startsAt);
|
|
54
|
+
if (Number.isNaN(startMs)) return { ok: false, reason: "invalid" };
|
|
55
|
+
const endMs = startMs + svc.durationMin * 60_000;
|
|
56
|
+
const startsAt = new Date(startMs).toISOString();
|
|
57
|
+
const endsAt = new Date(endMs).toISOString();
|
|
58
|
+
|
|
59
|
+
// Must be far enough in the future.
|
|
60
|
+
const minStart = Date.now() + siteConfig.booking.leadTimeHours * 3_600_000;
|
|
61
|
+
if (startMs < minStart) return { ok: false, reason: "past" };
|
|
62
|
+
|
|
63
|
+
// Serialize the check-then-insert for this calendar day so two concurrent
|
|
64
|
+
// bookings can't both pass the overlap check. Held until this tx commits.
|
|
65
|
+
await ctx.db.advisoryLock(`booking_day:${startsAt.slice(0, 10)}`);
|
|
66
|
+
|
|
67
|
+
// Re-check overlap against existing busy slots (cross-user read → unsafe).
|
|
68
|
+
const busy = (await ctx.db.unsafe.list("BookedSlot")) as unknown as {
|
|
69
|
+
startsAt: string;
|
|
70
|
+
endsAt: string;
|
|
71
|
+
}[];
|
|
72
|
+
const conflict = busy.some((b) =>
|
|
73
|
+
rangesOverlap(startMs, endMs, Date.parse(b.startsAt), Date.parse(b.endsAt)),
|
|
74
|
+
);
|
|
75
|
+
if (conflict) return { ok: false, reason: "taken" };
|
|
76
|
+
|
|
77
|
+
// Booking denies all client access by policy; createBooking is the only
|
|
78
|
+
// writer, so these go through the trusted `unsafe` surface.
|
|
79
|
+
const bookingId = await ctx.db.unsafe.insert("Booking", {
|
|
80
|
+
serviceSlug: svc.slug,
|
|
81
|
+
startsAt,
|
|
82
|
+
endsAt,
|
|
83
|
+
customerName: name,
|
|
84
|
+
customerEmail: email,
|
|
85
|
+
customerPhone: phone,
|
|
86
|
+
status: "pending",
|
|
87
|
+
createdAt: new Date().toISOString(),
|
|
88
|
+
});
|
|
89
|
+
await ctx.db.unsafe.insert("BookedSlot", {
|
|
90
|
+
serviceSlug: svc.slug,
|
|
91
|
+
startsAt,
|
|
92
|
+
endsAt,
|
|
93
|
+
bookingId,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return { ok: true };
|
|
97
|
+
},
|
|
98
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Shape of a booking row as the owner dashboard sees it, shared by the server
|
|
2
|
+
// query (functions/bookingsForOwner.ts) and the client view
|
|
3
|
+
// (app/dashboard/dashboard-client.tsx). The client imports only the type, never
|
|
4
|
+
// server code.
|
|
5
|
+
|
|
6
|
+
export interface BookingRow {
|
|
7
|
+
id: string;
|
|
8
|
+
serviceSlug: string;
|
|
9
|
+
startsAt: string;
|
|
10
|
+
endsAt: string;
|
|
11
|
+
customerName: string;
|
|
12
|
+
customerEmail: string;
|
|
13
|
+
customerPhone?: string | null;
|
|
14
|
+
status: string; // "pending" | "confirmed" | "cancelled"
|
|
15
|
+
createdAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// bookingsForOwner returns a discriminated result rather than throwing on a
|
|
19
|
+
// non-owner: a query has no `ctx.error`, and a bare throw reaches the client as
|
|
20
|
+
// a stripped HANDLER_ERROR. A non-owner gets `{ authorized: false }` and NO
|
|
21
|
+
// booking data.
|
|
22
|
+
export type OwnerBookingsResult =
|
|
23
|
+
| { authorized: true; bookings: BookingRow[] }
|
|
24
|
+
| { authorized: false };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Who owns this waitlist? A waitlist is single-tenant — one business, one
|
|
2
|
+
// owner — so ownership is just "the email the owner signs in with", configured
|
|
3
|
+
// once via the PYLON_OWNER_EMAIL env var. The owner-only `waitlistStats`
|
|
4
|
+
// function reads that env (via `ctx.env`) and compares it here.
|
|
5
|
+
//
|
|
6
|
+
// Fail closed: if PYLON_OWNER_EMAIL is unset, NOBODY is the owner and the
|
|
7
|
+
// dashboard stays locked. That's deliberate — an unset owner on a public site
|
|
8
|
+
// must not mean "everyone can read the signups". Set it in .env (see
|
|
9
|
+
// .env.example) before signing in.
|
|
10
|
+
|
|
11
|
+
export function normalizeOwner(raw: string | null | undefined): string | null {
|
|
12
|
+
const v = raw?.trim().toLowerCase();
|
|
13
|
+
return v && v.length > 0 ? v : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Pure comparator — the caller supplies the configured owner value (from
|
|
17
|
+
// `ctx.env.PYLON_OWNER_EMAIL`), so the rule lives in one place and stays
|
|
18
|
+
// testable without reaching for the environment here.
|
|
19
|
+
export function emailMatchesOwner(
|
|
20
|
+
email: string | null | undefined,
|
|
21
|
+
ownerRaw: string | null | undefined,
|
|
22
|
+
): boolean {
|
|
23
|
+
const owner = normalizeOwner(ownerRaw);
|
|
24
|
+
if (!owner) return false;
|
|
25
|
+
return (email ?? "").trim().toLowerCase() === owner;
|
|
26
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// THE single source of truth for everything business-specific. Rebrand the
|
|
2
|
+
// whole site — and reconfigure the booking engine — by editing this ONE file.
|
|
3
|
+
// The landing page, layout, AND the createBooking server function all read from
|
|
4
|
+
// here, so services, prices, weekly hours, and lead time stay in lockstep. The
|
|
5
|
+
// create-pylon scaffolder and Mast target this file: a whole appointment site
|
|
6
|
+
// is themed + configured by producing one typed object.
|
|
7
|
+
//
|
|
8
|
+
// Colors live here (applied as CSS variables on <html> in app/layout.tsx).
|
|
9
|
+
//
|
|
10
|
+
// Fictional demo copy — replace the values, keep the shape.
|
|
11
|
+
|
|
12
|
+
/* ----------------------------- types ----------------------------- */
|
|
13
|
+
|
|
14
|
+
export type Social = { label: string; href: string; path: string };
|
|
15
|
+
|
|
16
|
+
export type BaseConfig = {
|
|
17
|
+
brand: {
|
|
18
|
+
name: string;
|
|
19
|
+
letter: string;
|
|
20
|
+
domain: string;
|
|
21
|
+
email: string;
|
|
22
|
+
footerBlurb: string;
|
|
23
|
+
copyrightName: string;
|
|
24
|
+
socials: Social[];
|
|
25
|
+
};
|
|
26
|
+
colors: { brand: string; brandSoft: string; paper: string };
|
|
27
|
+
seo: { title: string; description: string };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ServiceItem = {
|
|
31
|
+
slug: string;
|
|
32
|
+
name: string;
|
|
33
|
+
durationMin: number; // drives the booking slot length
|
|
34
|
+
price: string; // display only, e.g. "$35"
|
|
35
|
+
description?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type DayHours = { open: string; close: string } | null; // "09:00".."18:00"
|
|
39
|
+
|
|
40
|
+
export type Review = { quote: string; name: string; rating?: number };
|
|
41
|
+
export type Faq = { q: string; a: string };
|
|
42
|
+
|
|
43
|
+
export type LocalServiceConfig = BaseConfig & {
|
|
44
|
+
hero: {
|
|
45
|
+
tagline: string;
|
|
46
|
+
headline: string;
|
|
47
|
+
subcopy: string;
|
|
48
|
+
ctaLabel: string;
|
|
49
|
+
quickFacts: { hours: string; area: string; phone: string };
|
|
50
|
+
};
|
|
51
|
+
services: {
|
|
52
|
+
eyebrow: string;
|
|
53
|
+
headline: string;
|
|
54
|
+
items: ServiceItem[];
|
|
55
|
+
};
|
|
56
|
+
booking: {
|
|
57
|
+
enabled: boolean;
|
|
58
|
+
eyebrow: string;
|
|
59
|
+
headline: string;
|
|
60
|
+
subcopy: string;
|
|
61
|
+
slotMinutes: number; // granularity of offered start times, e.g. 30
|
|
62
|
+
leadTimeHours: number; // earliest bookable lead time from now
|
|
63
|
+
daysAhead: number; // how many days the picker offers
|
|
64
|
+
// Weekly hours keyed by day-of-week (0=Sun … 6=Sat). null = closed.
|
|
65
|
+
hours: Record<number, DayHours>;
|
|
66
|
+
confirmationMessage: string;
|
|
67
|
+
};
|
|
68
|
+
reviews?: { eyebrow: string; headline: string; items: Review[] };
|
|
69
|
+
location: {
|
|
70
|
+
eyebrow: string;
|
|
71
|
+
headline: string;
|
|
72
|
+
address: string;
|
|
73
|
+
mapEmbedUrl?: string;
|
|
74
|
+
hoursText: string;
|
|
75
|
+
phone: string;
|
|
76
|
+
email: string;
|
|
77
|
+
};
|
|
78
|
+
faq?: { eyebrow: string; headline: string; items: Faq[] };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/* ----------------------------- config ---------------------------- */
|
|
82
|
+
|
|
83
|
+
export const siteConfig: LocalServiceConfig = {
|
|
84
|
+
brand: {
|
|
85
|
+
name: "Northgate Barbers",
|
|
86
|
+
letter: "N",
|
|
87
|
+
domain: "northgatebarbers.com",
|
|
88
|
+
email: "hello@northgatebarbers.example",
|
|
89
|
+
footerBlurb:
|
|
90
|
+
"A neighborhood barbershop in Dallas. Classic cuts, hot-towel shaves, and a chair that's always ready. Book in ten seconds.",
|
|
91
|
+
copyrightName: "Northgate Barbers",
|
|
92
|
+
socials: [
|
|
93
|
+
{
|
|
94
|
+
label: "Instagram",
|
|
95
|
+
href: "https://instagram.com",
|
|
96
|
+
path: "M12 2.2c3.2 0 3.6 0 4.85.07 1.17.05 1.8.25 2.23.41.56.22.96.48 1.38.9.42.42.68.82.9 1.38.16.42.36 1.06.41 2.23.06 1.27.07 1.65.07 4.85s0 3.58-.07 4.85c-.05 1.17-.25 1.8-.41 2.23-.22.56-.48.96-.9 1.38-.42.42-.82.68-1.38.9-.42.16-1.06.36-2.23.41-1.27.06-1.65.07-4.85.07s-3.58 0-4.85-.07c-1.17-.05-1.8-.25-2.23-.41a3.7 3.7 0 0 1-1.38-.9 3.7 3.7 0 0 1-.9-1.38c-.16-.42-.36-1.06-.41-2.23C2.2 15.58 2.2 15.2 2.2 12s0-3.58.07-4.85c.05-1.17.25-1.8.41-2.23.22-.56.48-.96.9-1.38.42-.42.82-.68 1.38-.9.42-.16 1.06-.36 2.23-.41C8.42 2.2 8.8 2.2 12 2.2zm0 1.8c-3.15 0-3.5 0-4.74.07-.9.04-1.38.19-1.7.32-.43.16-.74.36-1.06.68-.32.32-.52.63-.68 1.06-.13.32-.28.8-.32 1.7C3.8 8.5 3.8 8.85 3.8 12s0 3.5.07 4.74c.04.9.19 1.38.32 1.7.16.43.36.74.68 1.06.32.32.63.52 1.06.68.32.13.8.28 1.7.32 1.24.07 1.59.07 4.74.07s3.5 0 4.74-.07c.9-.04 1.38-.19 1.7-.32.43-.16.74-.36 1.06-.68.32-.32.52-.63.68-1.06.13-.32.28-.8.32-1.7.07-1.24.07-1.59.07-4.74s0-3.5-.07-4.74c-.04-.9-.19-1.38-.32-1.7a2.85 2.85 0 0 0-.68-1.06 2.85 2.85 0 0 0-1.06-.68c-.32-.13-.8-.28-1.7-.32C15.5 4 15.15 4 12 4zm0 3.06A4.94 4.94 0 1 0 12 16.94 4.94 4.94 0 0 0 12 7.06zm0 8.15A3.21 3.21 0 1 1 12 8.8a3.21 3.21 0 0 1 0 6.4zm6.3-8.35a1.15 1.15 0 1 1-2.3 0 1.15 1.15 0 0 1 2.3 0z",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
colors: { brand: "#b45309", brandSoft: "#fef3c7", paper: "#fafaf9" },
|
|
102
|
+
|
|
103
|
+
seo: {
|
|
104
|
+
title: "Northgate Barbers — classic cuts in Dallas. Book online.",
|
|
105
|
+
description:
|
|
106
|
+
"A neighborhood barbershop in Dallas. Haircuts, beard trims, and hot-towel shaves. See live availability and book your chair in seconds.",
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
hero: {
|
|
110
|
+
tagline: "Dallas · est. 2014",
|
|
111
|
+
headline: "A proper haircut, booked in ten seconds.",
|
|
112
|
+
subcopy:
|
|
113
|
+
"Classic cuts, beard work, and hot-towel shaves from barbers who've been at it a while. Pick a time that's actually open — availability updates live — and you're set.",
|
|
114
|
+
ctaLabel: "Book a chair",
|
|
115
|
+
quickFacts: {
|
|
116
|
+
hours: "Tue–Sat, 9–6",
|
|
117
|
+
area: "Lower Greenville, Dallas",
|
|
118
|
+
phone: "(214) 555-0148",
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
services: {
|
|
123
|
+
eyebrow: "Services",
|
|
124
|
+
headline: "Simple menu, honest prices.",
|
|
125
|
+
items: [
|
|
126
|
+
{
|
|
127
|
+
slug: "haircut",
|
|
128
|
+
name: "Haircut",
|
|
129
|
+
durationMin: 45,
|
|
130
|
+
price: "$35",
|
|
131
|
+
description: "Consultation, cut, and a clean finish. The classic.",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
slug: "beard-trim",
|
|
135
|
+
name: "Beard trim",
|
|
136
|
+
durationMin: 20,
|
|
137
|
+
price: "$18",
|
|
138
|
+
description: "Shape-up, line work, and hot-towel finish.",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
slug: "cut-and-beard",
|
|
142
|
+
name: "Cut + beard",
|
|
143
|
+
durationMin: 60,
|
|
144
|
+
price: "$48",
|
|
145
|
+
description: "The full sit-down. Haircut and beard, start to finish.",
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
slug: "kids-cut",
|
|
149
|
+
name: "Kids' cut",
|
|
150
|
+
durationMin: 30,
|
|
151
|
+
price: "$22",
|
|
152
|
+
description: "For the under-12s. Patient barbers, no rush.",
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
booking: {
|
|
158
|
+
enabled: true,
|
|
159
|
+
eyebrow: "Book",
|
|
160
|
+
headline: "Find a time that's open.",
|
|
161
|
+
subcopy:
|
|
162
|
+
"Pick a service and a day — open slots are live, so what you see is what's actually free. No account, no phone tag.",
|
|
163
|
+
slotMinutes: 30,
|
|
164
|
+
leadTimeHours: 2,
|
|
165
|
+
daysAhead: 14,
|
|
166
|
+
hours: {
|
|
167
|
+
0: null, // Sun — closed
|
|
168
|
+
1: null, // Mon — closed
|
|
169
|
+
2: { open: "09:00", close: "18:00" },
|
|
170
|
+
3: { open: "09:00", close: "18:00" },
|
|
171
|
+
4: { open: "09:00", close: "19:00" },
|
|
172
|
+
5: { open: "09:00", close: "19:00" },
|
|
173
|
+
6: { open: "09:00", close: "16:00" }, // Sat — shorter
|
|
174
|
+
},
|
|
175
|
+
confirmationMessage:
|
|
176
|
+
"You're booked. We'll see you then — a reminder will go out the day before.",
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
reviews: {
|
|
180
|
+
eyebrow: "Reviews",
|
|
181
|
+
headline: "Regulars say it best.",
|
|
182
|
+
items: [
|
|
183
|
+
{
|
|
184
|
+
quote:
|
|
185
|
+
"Best fade in Dallas, and I can finally book online instead of waiting around. Booked, in, out, sharp.",
|
|
186
|
+
name: "Marcus B.",
|
|
187
|
+
rating: 5,
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
quote:
|
|
191
|
+
"Been coming for three years. Same great cut every time, and the online booking is dead simple.",
|
|
192
|
+
name: "Daniel R.",
|
|
193
|
+
rating: 5,
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
quote:
|
|
197
|
+
"Took my son for his first real haircut. Patient, friendly, and the chair was ready right on time.",
|
|
198
|
+
name: "Hannah K.",
|
|
199
|
+
rating: 5,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
location: {
|
|
205
|
+
eyebrow: "Visit",
|
|
206
|
+
headline: "Find us on Greenville.",
|
|
207
|
+
address: "1845 Greenville Ave, Dallas, TX 75206",
|
|
208
|
+
mapEmbedUrl: "",
|
|
209
|
+
hoursText: "Tue–Wed 9–6 · Thu–Fri 9–7 · Sat 9–4 · Sun–Mon closed",
|
|
210
|
+
phone: "(214) 555-0148",
|
|
211
|
+
email: "hello@northgatebarbers.example",
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
faq: {
|
|
215
|
+
eyebrow: "Questions",
|
|
216
|
+
headline: "Good to know.",
|
|
217
|
+
items: [
|
|
218
|
+
{
|
|
219
|
+
q: "Do you take walk-ins?",
|
|
220
|
+
a: "When a chair's open, sure — but booking online guarantees your time, and you can see exactly what's free.",
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
q: "What if I need to cancel?",
|
|
224
|
+
a: "Just give us a call. No charge for cancellations with a few hours' notice.",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
q: "How should I pay?",
|
|
228
|
+
a: "Cash or card in the shop. Booking online doesn't charge you anything — you pay after the cut.",
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
};
|