@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,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, type Metadata, type PageProps } from "@pylonsync/react";
|
|
3
|
+
import { AuthForm } from "../auth-form";
|
|
4
|
+
import { siteConfig } from "@/lib/site.config";
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = {
|
|
7
|
+
title: `Sign in — ${siteConfig.brand.name}`,
|
|
8
|
+
robots: "noindex",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// `app/login/page.tsx` → `/login`. The owner's sign-in. Rendered bare (the
|
|
12
|
+
// layout suppresses the marketing nav/footer for /login). Already signed in?
|
|
13
|
+
// Skip straight to the dashboard — `response.redirect` in the synchronous shell
|
|
14
|
+
// render is a real 307 before any HTML is sent.
|
|
15
|
+
export default function LoginPage({ auth, response }: PageProps) {
|
|
16
|
+
// Already signed in (a real account, not an anonymous guest)? Skip the form.
|
|
17
|
+
if (auth.user_id && !auth.user_id.startsWith("guest_")) response.redirect("/dashboard");
|
|
18
|
+
const { brand } = siteConfig;
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex min-h-screen items-center justify-center bg-white px-6 py-12">
|
|
21
|
+
<div className="w-full max-w-[400px] rounded-2xl border border-zinc-200/70 p-8">
|
|
22
|
+
<Link href="/" className="inline-flex">
|
|
23
|
+
<span className="flex size-9 items-center justify-center rounded-xl bg-zinc-900 text-base font-bold text-white">
|
|
24
|
+
{brand.letter}
|
|
25
|
+
</span>
|
|
26
|
+
</Link>
|
|
27
|
+
<h1 className="mt-5 text-[22px] font-semibold tracking-tight text-zinc-900">
|
|
28
|
+
{brand.name} dashboard
|
|
29
|
+
</h1>
|
|
30
|
+
<p className="mt-1 text-[13px] text-zinc-500">
|
|
31
|
+
Sign in to manage your bookings.
|
|
32
|
+
</p>
|
|
33
|
+
<div className="mt-6">
|
|
34
|
+
<AuthForm />
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Link, type NotFoundProps } from "@pylonsync/react";
|
|
3
|
+
|
|
4
|
+
// `app/not-found.tsx` → rendered at HTTP 404 for any unmatched URL (and when a
|
|
5
|
+
// page calls `response.notFound()`). Hydrated, so the link is a client nav.
|
|
6
|
+
export default function NotFound(_props: NotFoundProps) {
|
|
7
|
+
return (
|
|
8
|
+
<div className="mx-auto flex min-h-[60vh] max-w-3xl flex-col items-center justify-center px-6 text-center">
|
|
9
|
+
<h1 className="text-3xl font-semibold tracking-tight">404</h1>
|
|
10
|
+
<p className="mt-2 text-zinc-500">We couldn't find that page.</p>
|
|
11
|
+
<Link
|
|
12
|
+
href="/"
|
|
13
|
+
className="mt-6 inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 text-sm font-medium text-white transition-colors hover:bg-zinc-700"
|
|
14
|
+
>
|
|
15
|
+
Back home
|
|
16
|
+
</Link>
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { type Metadata } from "@pylonsync/react";
|
|
3
|
+
import {
|
|
4
|
+
WRAP,
|
|
5
|
+
Eyebrow,
|
|
6
|
+
Divider,
|
|
7
|
+
SectionHead,
|
|
8
|
+
ImagePlaceholder,
|
|
9
|
+
LiveBadge,
|
|
10
|
+
} from "@/components/marketing";
|
|
11
|
+
import { BookingWidget } from "./booking-widget";
|
|
12
|
+
import { siteConfig } from "@/lib/site.config";
|
|
13
|
+
|
|
14
|
+
export const metadata: Metadata = {
|
|
15
|
+
title: siteConfig.seo.title,
|
|
16
|
+
description: siteConfig.seo.description,
|
|
17
|
+
openGraph: {
|
|
18
|
+
title: siteConfig.seo.title,
|
|
19
|
+
description: siteConfig.seo.description,
|
|
20
|
+
type: "website",
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// `app/page.tsx` → `/`. Server-rendered single-page site for an appointment
|
|
25
|
+
// business. Hero, services, reviews, location, and FAQ are static server HTML
|
|
26
|
+
// (SEO + first paint); the booking section (#book) is a client island
|
|
27
|
+
// (<BookingWidget>) with live slot availability. All copy comes from siteConfig.
|
|
28
|
+
//
|
|
29
|
+
// Does not read `auth`, so the public page stays cacheable; the nav's signed-in
|
|
30
|
+
// state is resolved in the layout.
|
|
31
|
+
export default function LandingPage() {
|
|
32
|
+
const { hero, services, booking, reviews, location, faq } = siteConfig;
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="bg-white text-zinc-900">
|
|
36
|
+
{/* ============================= HERO ============================= */}
|
|
37
|
+
<section className={`${WRAP} pt-16 pb-14 sm:pt-20`}>
|
|
38
|
+
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-14">
|
|
39
|
+
<div>
|
|
40
|
+
<p className="font-mono text-[11px] uppercase tracking-[0.16em] text-brand">
|
|
41
|
+
{hero.tagline}
|
|
42
|
+
</p>
|
|
43
|
+
<h1 className="mt-4 text-balance text-[2.5rem] font-semibold leading-[1.04] tracking-[-0.02em] sm:text-[3.25rem]">
|
|
44
|
+
{hero.headline}
|
|
45
|
+
</h1>
|
|
46
|
+
<p className="mt-5 max-w-xl text-[17px] leading-relaxed text-zinc-500">
|
|
47
|
+
{hero.subcopy}
|
|
48
|
+
</p>
|
|
49
|
+
<div className="mt-7 flex flex-wrap items-center gap-4">
|
|
50
|
+
<a
|
|
51
|
+
href="#book"
|
|
52
|
+
className="inline-flex items-center rounded-full bg-brand px-5 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90"
|
|
53
|
+
>
|
|
54
|
+
{hero.ctaLabel}
|
|
55
|
+
</a>
|
|
56
|
+
<a href="#services" className="text-sm font-medium text-zinc-700 hover:text-zinc-900">
|
|
57
|
+
See the menu →
|
|
58
|
+
</a>
|
|
59
|
+
</div>
|
|
60
|
+
<dl className="mt-10 grid max-w-lg grid-cols-3 gap-4 border-t border-zinc-200/70 pt-6">
|
|
61
|
+
<QuickFact label="Hours" value={hero.quickFacts.hours} />
|
|
62
|
+
<QuickFact label="Area" value={hero.quickFacts.area} />
|
|
63
|
+
<QuickFact label="Call" value={hero.quickFacts.phone} />
|
|
64
|
+
</dl>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/* Hero photo — replace the placeholder with a real shot of your shop. */}
|
|
68
|
+
<div className="relative mx-auto w-full max-w-sm lg:max-w-none">
|
|
69
|
+
<ImagePlaceholder
|
|
70
|
+
shape="portrait"
|
|
71
|
+
title="A photo of your shop"
|
|
72
|
+
hint="Swap for an <img> in app/page.tsx"
|
|
73
|
+
/>
|
|
74
|
+
<div className="absolute left-4 top-4">
|
|
75
|
+
<LiveBadge>Availability updates live</LiveBadge>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
</section>
|
|
80
|
+
|
|
81
|
+
{/* =========================== SERVICES ========================== */}
|
|
82
|
+
<Divider />
|
|
83
|
+
<section id="services" className={`${WRAP} py-14`}>
|
|
84
|
+
<SectionHead eyebrow={services.eyebrow} title={services.headline} />
|
|
85
|
+
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
|
86
|
+
{services.items.map((s) => (
|
|
87
|
+
<div
|
|
88
|
+
key={s.slug}
|
|
89
|
+
className="flex items-start justify-between gap-4 rounded-xl border border-zinc-200 bg-paper p-5"
|
|
90
|
+
>
|
|
91
|
+
<div>
|
|
92
|
+
<h3 className="text-[15px] font-semibold text-zinc-900">{s.name}</h3>
|
|
93
|
+
{s.description ? (
|
|
94
|
+
<p className="mt-1.5 text-[13.5px] leading-relaxed text-zinc-500">
|
|
95
|
+
{s.description}
|
|
96
|
+
</p>
|
|
97
|
+
) : null}
|
|
98
|
+
<p className="mt-2 text-[12px] uppercase tracking-wide text-zinc-400">
|
|
99
|
+
{s.durationMin} min
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
<div className="shrink-0 text-[15px] font-semibold text-brand">{s.price}</div>
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
</div>
|
|
106
|
+
</section>
|
|
107
|
+
|
|
108
|
+
{/* ============================ BOOKING ========================== */}
|
|
109
|
+
{booking.enabled ? (
|
|
110
|
+
<>
|
|
111
|
+
<Divider />
|
|
112
|
+
<section id="book" className={`${WRAP} py-14`}>
|
|
113
|
+
<SectionHead
|
|
114
|
+
eyebrow={booking.eyebrow}
|
|
115
|
+
title={booking.headline}
|
|
116
|
+
body={booking.subcopy}
|
|
117
|
+
/>
|
|
118
|
+
<div className="mt-8">
|
|
119
|
+
<BookingWidget />
|
|
120
|
+
</div>
|
|
121
|
+
</section>
|
|
122
|
+
</>
|
|
123
|
+
) : null}
|
|
124
|
+
|
|
125
|
+
{/* ============================ REVIEWS ========================== */}
|
|
126
|
+
{reviews && reviews.items.length > 0 ? (
|
|
127
|
+
<>
|
|
128
|
+
<Divider />
|
|
129
|
+
<section className={`${WRAP} py-14`}>
|
|
130
|
+
<SectionHead eyebrow={reviews.eyebrow} title={reviews.headline} />
|
|
131
|
+
<div className="mt-8 grid gap-5 sm:grid-cols-3">
|
|
132
|
+
{reviews.items.map((r) => (
|
|
133
|
+
<figure
|
|
134
|
+
key={r.name}
|
|
135
|
+
className="flex flex-col rounded-2xl border border-zinc-200 bg-paper p-6"
|
|
136
|
+
>
|
|
137
|
+
{r.rating ? (
|
|
138
|
+
<div className="text-[13px] tracking-wide text-brand">
|
|
139
|
+
{"★".repeat(r.rating)}
|
|
140
|
+
<span className="text-zinc-200">{"★".repeat(5 - r.rating)}</span>
|
|
141
|
+
</div>
|
|
142
|
+
) : null}
|
|
143
|
+
<blockquote className="mt-3 text-[14px] leading-relaxed text-zinc-600">
|
|
144
|
+
“{r.quote}”
|
|
145
|
+
</blockquote>
|
|
146
|
+
<figcaption className="mt-4 text-[13px] font-semibold text-zinc-900">
|
|
147
|
+
{r.name}
|
|
148
|
+
</figcaption>
|
|
149
|
+
</figure>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
</section>
|
|
153
|
+
</>
|
|
154
|
+
) : null}
|
|
155
|
+
|
|
156
|
+
{/* =========================== LOCATION ========================== */}
|
|
157
|
+
<Divider />
|
|
158
|
+
<section className={`${WRAP} py-14`}>
|
|
159
|
+
<div className="grid gap-8 lg:grid-cols-[1fr_1fr]">
|
|
160
|
+
<div>
|
|
161
|
+
<SectionHead eyebrow={location.eyebrow} title={location.headline} />
|
|
162
|
+
<div className="mt-6 space-y-3 text-[14px] leading-relaxed text-zinc-600">
|
|
163
|
+
<p>{location.address}</p>
|
|
164
|
+
<p className="text-zinc-500">{location.hoursText}</p>
|
|
165
|
+
<p>
|
|
166
|
+
<a href={`tel:${location.phone}`} className="font-medium text-brand">
|
|
167
|
+
{location.phone}
|
|
168
|
+
</a>{" "}
|
|
169
|
+
·{" "}
|
|
170
|
+
<a href={`mailto:${location.email}`} className="text-zinc-600 hover:text-zinc-900">
|
|
171
|
+
{location.email}
|
|
172
|
+
</a>
|
|
173
|
+
</p>
|
|
174
|
+
<a
|
|
175
|
+
href="#book"
|
|
176
|
+
className="mt-2 inline-flex items-center rounded-full bg-zinc-900 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-zinc-700"
|
|
177
|
+
>
|
|
178
|
+
{siteConfig.hero.ctaLabel}
|
|
179
|
+
</a>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
{location.mapEmbedUrl ? (
|
|
183
|
+
<iframe
|
|
184
|
+
title="Map"
|
|
185
|
+
src={location.mapEmbedUrl}
|
|
186
|
+
className="h-64 w-full rounded-2xl border border-zinc-200"
|
|
187
|
+
loading="lazy"
|
|
188
|
+
/>
|
|
189
|
+
) : (
|
|
190
|
+
<div className="grid h-64 place-items-center rounded-2xl border border-zinc-200 bg-paper text-sm text-zinc-400">
|
|
191
|
+
Drop a Google Maps embed URL in <code className="mx-1">location.mapEmbedUrl</code>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</section>
|
|
196
|
+
|
|
197
|
+
{/* ============================== FAQ ============================ */}
|
|
198
|
+
{faq && faq.items.length > 0 ? (
|
|
199
|
+
<>
|
|
200
|
+
<Divider />
|
|
201
|
+
<section className={`${WRAP} py-14`}>
|
|
202
|
+
<Eyebrow>{faq.eyebrow}</Eyebrow>
|
|
203
|
+
<h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
|
|
204
|
+
{faq.headline}
|
|
205
|
+
</h2>
|
|
206
|
+
<div className="mt-8 divide-y divide-zinc-200/70 border-y border-zinc-200/70">
|
|
207
|
+
{faq.items.map((f) => (
|
|
208
|
+
<details key={f.q} className="group py-5">
|
|
209
|
+
<summary className="flex cursor-pointer items-center justify-between text-[15px] font-medium text-zinc-900 marker:hidden [&::-webkit-details-marker]:hidden">
|
|
210
|
+
{f.q}
|
|
211
|
+
<span className="text-brand transition-transform group-open:rotate-45">+</span>
|
|
212
|
+
</summary>
|
|
213
|
+
<p className="mt-3 max-w-2xl text-[14px] leading-relaxed text-zinc-500">
|
|
214
|
+
{f.a}
|
|
215
|
+
</p>
|
|
216
|
+
</details>
|
|
217
|
+
))}
|
|
218
|
+
</div>
|
|
219
|
+
</section>
|
|
220
|
+
</>
|
|
221
|
+
) : null}
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function QuickFact({ label, value }: { label: string; value: string }) {
|
|
227
|
+
return (
|
|
228
|
+
<div>
|
|
229
|
+
<dt className="text-[11px] font-medium uppercase tracking-wide text-zinc-400">{label}</dt>
|
|
230
|
+
<dd className="mt-1 text-[13.5px] font-medium text-zinc-900">{value}</dd>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Robots } from "@pylonsync/react";
|
|
2
|
+
|
|
3
|
+
// app/robots.ts → served at /robots.txt. Point SITE_URL at your domain in prod.
|
|
4
|
+
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
5
|
+
|
|
6
|
+
export default function robots(): Robots {
|
|
7
|
+
return {
|
|
8
|
+
// Keep the owner dashboard and the API out of the index.
|
|
9
|
+
rules: { userAgent: "*", allow: "/", disallow: ["/dashboard", "/login", "/api/"] },
|
|
10
|
+
sitemap: `${SITE}/sitemap.xml`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Sitemap } from "@pylonsync/react";
|
|
2
|
+
|
|
3
|
+
// app/sitemap.ts → served at /sitemap.xml. Point SITE_URL at your domain in
|
|
4
|
+
// production. The waitlist is a single public page, so the sitemap is just "/".
|
|
5
|
+
const SITE = process.env.SITE_URL ?? "http://localhost:4321";
|
|
6
|
+
|
|
7
|
+
export default async function sitemap(): Promise<Sitemap> {
|
|
8
|
+
return [{ url: `${SITE}/`, changeFrequency: "weekly", priority: 1 }];
|
|
9
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entity,
|
|
3
|
+
field,
|
|
4
|
+
policy,
|
|
5
|
+
auth,
|
|
6
|
+
buildManifest,
|
|
7
|
+
discoverAppRoutes,
|
|
8
|
+
} from "@pylonsync/sdk";
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// local-service — an appointment business (salon, barber, trainer, clinic,
|
|
12
|
+
// trades…) with LIVE slot availability. The realtime hook: the time picker
|
|
13
|
+
// subscribes to the day's booked slots, so the instant someone books, that
|
|
14
|
+
// slot greys out for everyone with the page open — no double-booking, no
|
|
15
|
+
// refresh. The server also re-checks at insert time to close the race.
|
|
16
|
+
//
|
|
17
|
+
// Two entities carry the booking; a third is the PII-free projection the public
|
|
18
|
+
// page is allowed to read:
|
|
19
|
+
// • Booking — the real appointment, with the customer's name/email/phone.
|
|
20
|
+
// Holds PII, so it denies ALL client reads/writes.
|
|
21
|
+
// • BookedSlot — a name/email-free { startsAt, endsAt } projection of a busy
|
|
22
|
+
// slot, PUBLIC-READ so the picker can grey out taken times
|
|
23
|
+
// live. createBooking writes both; cancelling frees the slot.
|
|
24
|
+
// • User — the business owner's account (email/password), for the
|
|
25
|
+
// dashboard.
|
|
26
|
+
//
|
|
27
|
+
// Services and weekly hours are CONFIG (lib/site.config.ts) — the generator
|
|
28
|
+
// produces one typed file. Bookings reference a service by its config `slug`.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
// The real appointment. `status` ∈ "pending" | "confirmed" | "cancelled".
|
|
32
|
+
// Everything except status is set once at booking time. Indexed by start so the
|
|
33
|
+
// owner dashboard + the server-side overlap re-check scan a day cheaply.
|
|
34
|
+
const Booking = entity(
|
|
35
|
+
"Booking",
|
|
36
|
+
{
|
|
37
|
+
serviceSlug: field.string(),
|
|
38
|
+
startsAt: field.datetime(),
|
|
39
|
+
endsAt: field.datetime(),
|
|
40
|
+
customerName: field.string(),
|
|
41
|
+
customerEmail: field.string(),
|
|
42
|
+
customerPhone: field.string().optional(),
|
|
43
|
+
status: field.string().default("pending"),
|
|
44
|
+
createdAt: field.datetime().defaultNow(),
|
|
45
|
+
},
|
|
46
|
+
{ indexes: [{ name: "by_start", fields: ["startsAt"], unique: false }] },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// The PII-FREE projection of a busy slot. This is the ONLY booking data the
|
|
50
|
+
// public page reads — just the time range (and which service), never a name or
|
|
51
|
+
// email. The picker subscribes with `db.useQuery("BookedSlot")` so a newly
|
|
52
|
+
// taken slot greys out across every open tab through the replica. Cancelling a
|
|
53
|
+
// booking deletes its BookedSlot, which frees the time live.
|
|
54
|
+
const BookedSlot = entity(
|
|
55
|
+
"BookedSlot",
|
|
56
|
+
{
|
|
57
|
+
serviceSlug: field.string(),
|
|
58
|
+
startsAt: field.datetime(),
|
|
59
|
+
endsAt: field.datetime(),
|
|
60
|
+
bookingId: field.id("Booking"),
|
|
61
|
+
},
|
|
62
|
+
{ indexes: [{ name: "by_start", fields: ["startsAt"], unique: false }] },
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// The business owner's account (email/password is built in against "User").
|
|
66
|
+
const User = entity(
|
|
67
|
+
"User",
|
|
68
|
+
{
|
|
69
|
+
email: field.string(),
|
|
70
|
+
displayName: field.string().optional(),
|
|
71
|
+
passwordHash: field.string().serverOnly().optional(),
|
|
72
|
+
avatarColor: field.string().optional(),
|
|
73
|
+
emailVerified: field.datetime().optional(),
|
|
74
|
+
createdAt: field.datetime().defaultNow(),
|
|
75
|
+
},
|
|
76
|
+
{ indexes: [{ name: "by_email", fields: ["email"], unique: true }] },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
// PRIVACY — Booking holds the customer's name/email/phone, so it denies EVERY
|
|
80
|
+
// client read and write. No `db.useQuery("Booking")` can pull a row. Writes go
|
|
81
|
+
// only through the createBooking mutation; the owner reads them only through the
|
|
82
|
+
// owner-gated `bookingsForOwner` function. A booking site must never leak its
|
|
83
|
+
// customers' contact details.
|
|
84
|
+
const bookingPolicy = policy({
|
|
85
|
+
name: "booking_private",
|
|
86
|
+
entity: "Booking",
|
|
87
|
+
allowRead: "false",
|
|
88
|
+
allowInsert: "false",
|
|
89
|
+
allowUpdate: "false",
|
|
90
|
+
allowDelete: "false",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// The busy-slot projection is public to READ (it's just a time range — the
|
|
94
|
+
// point is that the picker shows taken slots live to everyone). Clients can't
|
|
95
|
+
// WRITE it; only createBooking / cancelBooking maintain it server-side.
|
|
96
|
+
const bookedSlotPolicy = policy({
|
|
97
|
+
name: "booked_slot_public_read",
|
|
98
|
+
entity: "BookedSlot",
|
|
99
|
+
allowRead: "true",
|
|
100
|
+
allowInsert: "false",
|
|
101
|
+
allowUpdate: "false",
|
|
102
|
+
allowDelete: "false",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The owner reads their own User row (the owner gate resolves their email).
|
|
106
|
+
const userPolicy = policy({
|
|
107
|
+
name: "user_self",
|
|
108
|
+
entity: "User",
|
|
109
|
+
allowRead: "auth.userId == data.id",
|
|
110
|
+
allowInsert: "false",
|
|
111
|
+
allowUpdate: "false",
|
|
112
|
+
allowDelete: "false",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const manifest = buildManifest({
|
|
116
|
+
name: "__APP_NAME__",
|
|
117
|
+
version: "0.1.0",
|
|
118
|
+
entities: [Booking, BookedSlot, User],
|
|
119
|
+
// createBooking (public mutation), bookingsForOwner (owner query),
|
|
120
|
+
// confirmBooking / cancelBooking (owner mutations) live in functions/ and are
|
|
121
|
+
// discovered automatically.
|
|
122
|
+
queries: [],
|
|
123
|
+
actions: [],
|
|
124
|
+
policies: [bookingPolicy, bookedSlotPolicy, userPolicy],
|
|
125
|
+
auth: auth(),
|
|
126
|
+
routes: await discoverAppRoutes(),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
130
|
+
|
|
131
|
+
export default manifest;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
// Reusable presentational pieces for the landing page. All server-rendered —
|
|
4
|
+
// no client JS. Restyle here and the whole page follows. The brand accent
|
|
5
|
+
// (`text-brand`, `bg-brand-soft`) comes from CSS vars set on <html> in
|
|
6
|
+
// app/layout.tsx, which read lib/site.config.ts — so re-theming is one edit.
|
|
7
|
+
|
|
8
|
+
// Shared container: a contained, centered column.
|
|
9
|
+
export const WRAP = "mx-auto w-full max-w-5xl px-6";
|
|
10
|
+
|
|
11
|
+
export function Eyebrow({ children }: { children: React.ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-brand">
|
|
14
|
+
{children}
|
|
15
|
+
</p>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// "New / Coming soon"-style pill for the hero.
|
|
20
|
+
export function Badge({ children }: { children: React.ReactNode }) {
|
|
21
|
+
return (
|
|
22
|
+
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1.5 pr-3 text-[13px] text-zinc-600 shadow-sm">
|
|
23
|
+
<span className="inline-block size-1.5 rounded-full bg-brand" />
|
|
24
|
+
{children}
|
|
25
|
+
</span>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Divider() {
|
|
30
|
+
return (
|
|
31
|
+
<div className={WRAP}>
|
|
32
|
+
<div className="border-t border-zinc-200/70" />
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function SectionHead({
|
|
38
|
+
eyebrow,
|
|
39
|
+
title,
|
|
40
|
+
body,
|
|
41
|
+
}: {
|
|
42
|
+
eyebrow: string;
|
|
43
|
+
title: string;
|
|
44
|
+
body?: string;
|
|
45
|
+
}) {
|
|
46
|
+
return (
|
|
47
|
+
<div>
|
|
48
|
+
<Eyebrow>{eyebrow}</Eyebrow>
|
|
49
|
+
<h2 className="mt-4 text-balance text-2xl font-semibold leading-[1.15] tracking-[-0.02em] sm:text-3xl">
|
|
50
|
+
{title}
|
|
51
|
+
</h2>
|
|
52
|
+
{body ? (
|
|
53
|
+
<p className="mt-4 max-w-xl text-[15px] leading-relaxed text-zinc-500">
|
|
54
|
+
{body}
|
|
55
|
+
</p>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A grid of value props — icon + title + body.
|
|
62
|
+
export function FeatureGrid({
|
|
63
|
+
items,
|
|
64
|
+
}: {
|
|
65
|
+
items: { title: string; body: string; icon?: string }[];
|
|
66
|
+
}) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="grid gap-6 sm:grid-cols-3">
|
|
69
|
+
{items.map((f) => (
|
|
70
|
+
<div key={f.title}>
|
|
71
|
+
{f.icon ? (
|
|
72
|
+
<span className="flex size-9 items-center justify-center rounded-lg bg-brand-soft text-brand">
|
|
73
|
+
{f.icon}
|
|
74
|
+
</span>
|
|
75
|
+
) : null}
|
|
76
|
+
<h3 className="mt-4 text-[15px] font-semibold text-zinc-900">
|
|
77
|
+
{f.title}
|
|
78
|
+
</h3>
|
|
79
|
+
<p className="mt-2 text-[14px] leading-relaxed text-zinc-500">
|
|
80
|
+
{f.body}
|
|
81
|
+
</p>
|
|
82
|
+
</div>
|
|
83
|
+
))}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Initials for testimonial avatars, so the cards look finished without a photo.
|
|
89
|
+
export function initials(name: string) {
|
|
90
|
+
return name
|
|
91
|
+
.split(/\s+/)
|
|
92
|
+
.map((w) => w[0])
|
|
93
|
+
.join("")
|
|
94
|
+
.slice(0, 2)
|
|
95
|
+
.toUpperCase();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// A deliberately-obvious image placeholder. Real sites drop a photo here; this
|
|
99
|
+
// makes the spot unmistakable — dashed border, a photo glyph, and a one-line
|
|
100
|
+
// "swap this" instruction telling you exactly what to replace and where. Looks
|
|
101
|
+
// tidy enough to demo, but no one will mistake it for a finished design.
|
|
102
|
+
//
|
|
103
|
+
// shape — "landscape" | "portrait" | "square" | "circle"
|
|
104
|
+
// title — what photo belongs here ("A photo of your shop")
|
|
105
|
+
// hint — how to replace it ("Swap for an <img> in app/page.tsx")
|
|
106
|
+
export function ImagePlaceholder({
|
|
107
|
+
shape = "landscape",
|
|
108
|
+
title,
|
|
109
|
+
hint,
|
|
110
|
+
className = "",
|
|
111
|
+
}: {
|
|
112
|
+
shape?: "landscape" | "portrait" | "square" | "circle";
|
|
113
|
+
title: string;
|
|
114
|
+
hint?: string;
|
|
115
|
+
className?: string;
|
|
116
|
+
}) {
|
|
117
|
+
const aspect =
|
|
118
|
+
shape === "portrait"
|
|
119
|
+
? "aspect-[4/5]"
|
|
120
|
+
: shape === "square" || shape === "circle"
|
|
121
|
+
? "aspect-square"
|
|
122
|
+
: "aspect-[4/3]";
|
|
123
|
+
const radius = shape === "circle" ? "rounded-full" : "rounded-2xl";
|
|
124
|
+
return (
|
|
125
|
+
<div
|
|
126
|
+
className={`relative grid place-items-center overflow-hidden border-2 border-dashed border-zinc-300 bg-zinc-50 ${aspect} ${radius} ${className}`}
|
|
127
|
+
>
|
|
128
|
+
<div className="px-6 text-center">
|
|
129
|
+
<svg
|
|
130
|
+
className="mx-auto size-7 text-zinc-300"
|
|
131
|
+
viewBox="0 0 24 24"
|
|
132
|
+
fill="none"
|
|
133
|
+
stroke="currentColor"
|
|
134
|
+
strokeWidth="1.5"
|
|
135
|
+
strokeLinecap="round"
|
|
136
|
+
strokeLinejoin="round"
|
|
137
|
+
aria-hidden
|
|
138
|
+
>
|
|
139
|
+
<rect x="3" y="3" width="18" height="18" rx="2" />
|
|
140
|
+
<circle cx="9" cy="9" r="1.6" />
|
|
141
|
+
<path d="m21 15-4.5-4.5L7 20" />
|
|
142
|
+
</svg>
|
|
143
|
+
<p className="mt-3 text-[13px] font-medium text-zinc-500">{title}</p>
|
|
144
|
+
{hint ? <p className="mt-1 text-[11.5px] leading-snug text-zinc-400">{hint}</p> : null}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A small "live" pill — float it over a hero image to keep the realtime hook
|
|
151
|
+
// visible (e.g. "Availability updates live"). Pure decoration; no client JS.
|
|
152
|
+
export function LiveBadge({ children }: { children: React.ReactNode }) {
|
|
153
|
+
return (
|
|
154
|
+
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white/95 px-3 py-1.5 text-[12.5px] font-medium text-zinc-700 shadow-sm backdrop-blur">
|
|
155
|
+
<span className="relative flex size-2">
|
|
156
|
+
<span className="absolute inline-flex size-2 animate-ping rounded-full bg-green-500/60" />
|
|
157
|
+
<span className="relative inline-flex size-2 rounded-full bg-green-600" />
|
|
158
|
+
</span>
|
|
159
|
+
{children}
|
|
160
|
+
</span>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
// Makes in-page section links work. A hydrated Pylon page updates the URL for a
|
|
6
|
+
// plain `<a href="#section">` click but doesn't perform the browser's native
|
|
7
|
+
// fragment scroll, so the page jumps nowhere. This installs ONE delegated click
|
|
8
|
+
// handler that catches any same-page `#`/`/#` anchor and scrolls to it smoothly.
|
|
9
|
+
//
|
|
10
|
+
// Render it once (in the root layout). Renders nothing. Real route links should
|
|
11
|
+
// still use `<Link>` from @pylonsync/react — this only handles `#` anchors.
|
|
12
|
+
export function SectionScroller() {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
function onClick(e: MouseEvent) {
|
|
15
|
+
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const target = e.target as Element | null;
|
|
19
|
+
const link = target?.closest?.('a[href^="#"], a[href^="/#"]') as HTMLAnchorElement | null;
|
|
20
|
+
if (!link) return;
|
|
21
|
+
const href = link.getAttribute("href") || "";
|
|
22
|
+
const id = href.slice(href.indexOf("#") + 1);
|
|
23
|
+
if (!id) return;
|
|
24
|
+
const el = document.getElementById(id);
|
|
25
|
+
if (!el) return; // target not on this page — leave it to the browser
|
|
26
|
+
e.preventDefault();
|
|
27
|
+
el.scrollIntoView({ block: "start" });
|
|
28
|
+
history.replaceState(null, "", "#" + id);
|
|
29
|
+
}
|
|
30
|
+
document.addEventListener("click", onClick);
|
|
31
|
+
return () => document.removeEventListener("click", onClick);
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|