@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,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,42 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// bookInquiry — owner-only. Marks a lead "booked" AND consumes one project slot:
|
|
5
|
+
// it decrements Capacity.openSlots, which syncs to every open landing page, so
|
|
6
|
+
// the hero's "N slots open" counter ticks down live for everyone. Taking a
|
|
7
|
+
// per-row advisory lock on the capacity row makes the read-then-decrement
|
|
8
|
+
// race-safe. Idempotent: booking an already-booked lead doesn't double-count.
|
|
9
|
+
export default mutation<{ inquiryId: string }, { ok: boolean; openSlots: number }>({
|
|
10
|
+
auth: "user",
|
|
11
|
+
args: { inquiryId: v.id("Inquiry") },
|
|
12
|
+
async handler(ctx, args) {
|
|
13
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
14
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
15
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage inquiries.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const inquiry = (await ctx.db.get("Inquiry", args.inquiryId)) as
|
|
19
|
+
| { status: string }
|
|
20
|
+
| null;
|
|
21
|
+
if (!inquiry) throw ctx.error("NOT_FOUND", "Inquiry not found.");
|
|
22
|
+
|
|
23
|
+
await ctx.db.advisoryLock("agency_capacity");
|
|
24
|
+
const cap = ((await ctx.db.unsafe.list("Capacity")) as unknown as {
|
|
25
|
+
id: string;
|
|
26
|
+
openSlots: number;
|
|
27
|
+
}[])[0];
|
|
28
|
+
let openSlots = cap?.openSlots ?? 0;
|
|
29
|
+
|
|
30
|
+
// Only decrement when this lead is newly booked — re-booking is a no-op.
|
|
31
|
+
if (inquiry.status !== "booked" && cap) {
|
|
32
|
+
openSlots = Math.max(0, cap.openSlots - 1);
|
|
33
|
+
await ctx.db.unsafe.update("Capacity", cap.id, {
|
|
34
|
+
openSlots,
|
|
35
|
+
updatedAt: new Date().toISOString(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
await ctx.db.unsafe.update("Inquiry", args.inquiryId, { status: "booked" });
|
|
39
|
+
|
|
40
|
+
return { ok: true, openSlots };
|
|
41
|
+
},
|
|
42
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// declineInquiry — owner-only. Marks a lead "declined". If it had been "booked",
|
|
5
|
+
// it returns the project slot to the pool (Capacity.openSlots += 1), which syncs
|
|
6
|
+
// live so the hero counter ticks back up everywhere. Race-safe via the capacity
|
|
7
|
+
// advisory lock; idempotent on an already-declined lead.
|
|
8
|
+
export default mutation<{ inquiryId: string }, { ok: boolean; openSlots: number }>({
|
|
9
|
+
auth: "user",
|
|
10
|
+
args: { inquiryId: v.id("Inquiry") },
|
|
11
|
+
async handler(ctx, args) {
|
|
12
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
13
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
14
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage inquiries.");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const inquiry = (await ctx.db.get("Inquiry", args.inquiryId)) as
|
|
18
|
+
| { status: string }
|
|
19
|
+
| null;
|
|
20
|
+
if (!inquiry) throw ctx.error("NOT_FOUND", "Inquiry not found.");
|
|
21
|
+
|
|
22
|
+
await ctx.db.advisoryLock("agency_capacity");
|
|
23
|
+
const cap = ((await ctx.db.unsafe.list("Capacity")) as unknown as {
|
|
24
|
+
id: string;
|
|
25
|
+
openSlots: number;
|
|
26
|
+
}[])[0];
|
|
27
|
+
let openSlots = cap?.openSlots ?? 0;
|
|
28
|
+
|
|
29
|
+
// Releasing a previously-booked lead frees its slot again.
|
|
30
|
+
if (inquiry.status === "booked" && cap) {
|
|
31
|
+
openSlots = cap.openSlots + 1;
|
|
32
|
+
await ctx.db.unsafe.update("Capacity", cap.id, {
|
|
33
|
+
openSlots,
|
|
34
|
+
updatedAt: new Date().toISOString(),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
await ctx.db.unsafe.update("Inquiry", args.inquiryId, { status: "declined" });
|
|
38
|
+
|
|
39
|
+
return { ok: true, openSlots };
|
|
40
|
+
},
|
|
41
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { query } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import type { InquiryRow, OwnerInquiriesResult } from "../lib/agency";
|
|
4
|
+
|
|
5
|
+
// inquiriesForOwner — the owner's view of every project lead, INCLUDING the
|
|
6
|
+
// prospect's name, email, company, and budget. The one function allowed to
|
|
7
|
+
// return that PII, gated to the configured owner (PYLON_OWNER_EMAIL via
|
|
8
|
+
// ctx.env). A query has no `ctx.error`, so a non-owner gets `{ authorized:
|
|
9
|
+
// false }` (a bare throw would surface as a stripped HANDLER_ERROR) and NO data.
|
|
10
|
+
//
|
|
11
|
+
// The dashboard calls it with `callFn` and re-fetches whenever the public
|
|
12
|
+
// Capacity row changes — so booking a lead (which moves the counter) refreshes
|
|
13
|
+
// the pipeline without a page reload, while contact details never travel over
|
|
14
|
+
// entity sync.
|
|
15
|
+
export default query({
|
|
16
|
+
auth: "user",
|
|
17
|
+
async handler(ctx): Promise<OwnerInquiriesResult> {
|
|
18
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
19
|
+
const email = (me?.email as string | undefined) ?? null;
|
|
20
|
+
if (!emailMatchesOwner(email, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
21
|
+
return { authorized: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rows = (await ctx.db.unsafe.list("Inquiry")) as unknown as InquiryRow[];
|
|
25
|
+
const inquiries = rows
|
|
26
|
+
.map((r) => ({ ...r }))
|
|
27
|
+
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : a.createdAt > b.createdAt ? -1 : 0));
|
|
28
|
+
|
|
29
|
+
return { authorized: true, inquiries };
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mutation } from "@pylonsync/functions";
|
|
2
|
+
import { siteConfig } from "../lib/site.config";
|
|
3
|
+
|
|
4
|
+
// seedCapacity — create the single Capacity row from config on first visit
|
|
5
|
+
// (idempotent). The landing page calls this on mount; once a row exists it's a
|
|
6
|
+
// no-op, so it's safe to call on every load. A lock keeps two concurrent
|
|
7
|
+
// first-visits from creating two rows.
|
|
8
|
+
//
|
|
9
|
+
// Public so an anonymous first visitor seeds it — it only ever writes the
|
|
10
|
+
// config's booking window + open-slot count, never reads or returns anything
|
|
11
|
+
// sensitive.
|
|
12
|
+
export default mutation<Record<string, never>, { seeded: boolean }>({
|
|
13
|
+
auth: "public",
|
|
14
|
+
async handler(ctx) {
|
|
15
|
+
await ctx.db.advisoryLock("agency_seed_capacity");
|
|
16
|
+
const existing = await ctx.db.unsafe.list("Capacity");
|
|
17
|
+
if (existing.length > 0) return { seeded: false };
|
|
18
|
+
|
|
19
|
+
await ctx.db.unsafe.insert("Capacity", {
|
|
20
|
+
label: siteConfig.capacity.label,
|
|
21
|
+
openSlots: siteConfig.capacity.openSlots,
|
|
22
|
+
updatedAt: new Date().toISOString(),
|
|
23
|
+
});
|
|
24
|
+
return { seeded: true };
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
|
|
4
|
+
// setCapacity — owner-only. Set the booking window label and how many project
|
|
5
|
+
// slots are open (e.g. at the start of a new quarter). The update syncs to
|
|
6
|
+
// every open landing page, so the hero counter reflects it live. Creates the
|
|
7
|
+
// row if it doesn't exist yet.
|
|
8
|
+
export default mutation<
|
|
9
|
+
{ label: string; openSlots: number },
|
|
10
|
+
{ ok: boolean; openSlots: number }
|
|
11
|
+
>({
|
|
12
|
+
auth: "user",
|
|
13
|
+
args: { label: v.string(), openSlots: v.int() },
|
|
14
|
+
async handler(ctx, args) {
|
|
15
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
16
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
17
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage capacity.");
|
|
18
|
+
}
|
|
19
|
+
const label = args.label.trim().slice(0, 60);
|
|
20
|
+
const openSlots = Math.max(0, Math.trunc(args.openSlots));
|
|
21
|
+
|
|
22
|
+
await ctx.db.advisoryLock("agency_capacity");
|
|
23
|
+
const cap = ((await ctx.db.unsafe.list("Capacity")) as unknown as { id: string }[])[0];
|
|
24
|
+
const now = new Date().toISOString();
|
|
25
|
+
if (cap) {
|
|
26
|
+
await ctx.db.unsafe.update("Capacity", cap.id, { label, openSlots, updatedAt: now });
|
|
27
|
+
} else {
|
|
28
|
+
await ctx.db.unsafe.insert("Capacity", { label, openSlots, updatedAt: now });
|
|
29
|
+
}
|
|
30
|
+
return { ok: true, openSlots };
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
|
|
3
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
4
|
+
|
|
5
|
+
// submitInquiry — a prospect's "start a project" lead. A `mutation` (it writes
|
|
6
|
+
// the Inquiry row transactionally). `auth: "public"` — a prospect has no
|
|
7
|
+
// account.
|
|
8
|
+
//
|
|
9
|
+
// PRIVACY: it returns only `{ ok }`, never an Inquiry row or anyone's details.
|
|
10
|
+
// Submitting does NOT consume a capacity slot — a lead isn't a booking. The
|
|
11
|
+
// owner books it from the dashboard, which is what decrements the live counter.
|
|
12
|
+
export default mutation<
|
|
13
|
+
{
|
|
14
|
+
name: string;
|
|
15
|
+
email: string;
|
|
16
|
+
company?: string;
|
|
17
|
+
projectType?: string;
|
|
18
|
+
budget?: string;
|
|
19
|
+
message?: string;
|
|
20
|
+
},
|
|
21
|
+
{ ok: boolean }
|
|
22
|
+
>({
|
|
23
|
+
auth: "public",
|
|
24
|
+
args: {
|
|
25
|
+
name: v.string(),
|
|
26
|
+
email: v.string(),
|
|
27
|
+
company: v.optional(v.string()),
|
|
28
|
+
projectType: v.optional(v.string()),
|
|
29
|
+
budget: v.optional(v.string()),
|
|
30
|
+
message: v.optional(v.string()),
|
|
31
|
+
},
|
|
32
|
+
async handler(ctx, args) {
|
|
33
|
+
const name = args.name.trim();
|
|
34
|
+
const email = args.email.trim().toLowerCase();
|
|
35
|
+
if (name.length < 1 || name.length > 120) {
|
|
36
|
+
throw ctx.error("INVALID_ARGS", "Please enter your name.");
|
|
37
|
+
}
|
|
38
|
+
if (!EMAIL_RE.test(email) || email.length > 254) {
|
|
39
|
+
throw ctx.error("INVALID_ARGS", "Please enter a valid email address.");
|
|
40
|
+
}
|
|
41
|
+
const clip = (s: string | undefined, max: number) => (s ? s.trim().slice(0, max) : null);
|
|
42
|
+
|
|
43
|
+
await ctx.db.unsafe.insert("Inquiry", {
|
|
44
|
+
name,
|
|
45
|
+
email,
|
|
46
|
+
company: clip(args.company, 160),
|
|
47
|
+
projectType: clip(args.projectType, 80),
|
|
48
|
+
budget: clip(args.budget, 80),
|
|
49
|
+
message: clip(args.message, 4000),
|
|
50
|
+
status: "new",
|
|
51
|
+
createdAt: new Date().toISOString(),
|
|
52
|
+
});
|
|
53
|
+
return { ok: true };
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Shared agency types. The Inquiry row is what the owner dashboard sees (with
|
|
2
|
+
// PII); the client imports only the type, never server code.
|
|
3
|
+
|
|
4
|
+
export interface InquiryRow {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
email: string;
|
|
8
|
+
company?: string | null;
|
|
9
|
+
projectType?: string | null;
|
|
10
|
+
budget?: string | null;
|
|
11
|
+
message?: string | null;
|
|
12
|
+
status: string; // "new" | "booked" | "declined"
|
|
13
|
+
createdAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// inquiriesForOwner returns a discriminated result rather than throwing on a
|
|
17
|
+
// non-owner (a query has no `ctx.error`; a bare throw becomes a stripped
|
|
18
|
+
// HANDLER_ERROR). A non-owner gets `{ authorized: false }` and NO data.
|
|
19
|
+
export type OwnerInquiriesResult =
|
|
20
|
+
| { authorized: true; inquiries: InquiryRow[] }
|
|
21
|
+
| { authorized: false };
|
|
22
|
+
|
|
23
|
+
// The public, PII-free capacity the landing page reads live.
|
|
24
|
+
export interface CapacityData {
|
|
25
|
+
label: string; // booking window, e.g. "Q3 2026"
|
|
26
|
+
openSlots: number;
|
|
27
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Who owns this studio site? A studio 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 functions
|
|
4
|
+
// (inquiriesForOwner etc.) read that env (via `ctx.env`) and compare 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 inquiries". 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,239 @@
|
|
|
1
|
+
// THE single source of truth for everything business-specific on this agency
|
|
2
|
+
// site. Rebrand the whole studio by editing this ONE file — the landing page,
|
|
3
|
+
// layout, and the seedCapacity function all read from here. The create-pylon
|
|
4
|
+
// scaffolder and Mast target this file: a whole studio site is themed from one
|
|
5
|
+
// typed object.
|
|
6
|
+
//
|
|
7
|
+
// Colors live here (applied as CSS variables on <html> in app/layout.tsx).
|
|
8
|
+
// Fictional demo copy — replace the values, keep the shape. Anywhere a real
|
|
9
|
+
// photo belongs (case-study shots, team headshots) the page renders a clearly
|
|
10
|
+
// marked placeholder — swap those for <img>s when you have the assets.
|
|
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; // monogram
|
|
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 Service = { title: string; body: string; icon?: string };
|
|
31
|
+
export type CaseStudy = { title: string; client: string; summary: string; tags: string[] };
|
|
32
|
+
export type ProcessStep = { title: string; body: string };
|
|
33
|
+
export type TeamMember = { name: string; role: string };
|
|
34
|
+
export type Testimonial = { quote: string; name: string; role: string };
|
|
35
|
+
|
|
36
|
+
export type AgencyConfig = BaseConfig & {
|
|
37
|
+
hero: {
|
|
38
|
+
tagline: string;
|
|
39
|
+
headline: string;
|
|
40
|
+
subcopy: string;
|
|
41
|
+
ctaLabel: string;
|
|
42
|
+
secondaryCtaLabel: string;
|
|
43
|
+
};
|
|
44
|
+
// Seeds the public Capacity row on first visit; after that it lives in the DB
|
|
45
|
+
// and the owner manages it from the dashboard. `openSlots` is the number the
|
|
46
|
+
// hero shows live; `label` is the booking window it refers to.
|
|
47
|
+
capacity: { label: string; openSlots: number };
|
|
48
|
+
logos: { eyebrow: string; names: string[] };
|
|
49
|
+
services: { eyebrow: string; headline: string; items: Service[] };
|
|
50
|
+
work: { eyebrow: string; headline: string; items: CaseStudy[] };
|
|
51
|
+
process: { eyebrow: string; headline: string; steps: ProcessStep[] };
|
|
52
|
+
team: { eyebrow: string; headline: string; members: TeamMember[] };
|
|
53
|
+
testimonials?: { eyebrow: string; headline: string; items: Testimonial[] };
|
|
54
|
+
contact: {
|
|
55
|
+
eyebrow: string;
|
|
56
|
+
headline: string;
|
|
57
|
+
subcopy: string;
|
|
58
|
+
projectTypes: string[];
|
|
59
|
+
budgets: string[];
|
|
60
|
+
confirmationMessage: string;
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/* ----------------------------- config ---------------------------- */
|
|
65
|
+
|
|
66
|
+
export const siteConfig: AgencyConfig = {
|
|
67
|
+
brand: {
|
|
68
|
+
name: "Halyard",
|
|
69
|
+
letter: "H",
|
|
70
|
+
domain: "halyard.studio",
|
|
71
|
+
email: "hello@halyard.example",
|
|
72
|
+
footerBlurb:
|
|
73
|
+
"A product studio in Dallas. We design and build the software ambitious teams bet on — and we only take on a few projects at a time, so the work stays sharp.",
|
|
74
|
+
copyrightName: "Halyard Studio",
|
|
75
|
+
socials: [
|
|
76
|
+
{
|
|
77
|
+
label: "X",
|
|
78
|
+
href: "https://x.com",
|
|
79
|
+
path: "M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
label: "LinkedIn",
|
|
83
|
+
href: "https://linkedin.com",
|
|
84
|
+
path: "M20.45 20.45h-3.56v-5.57c0-1.33-.02-3.04-1.85-3.04-1.85 0-2.13 1.45-2.13 2.94v5.67H9.35V9h3.42v1.56h.05c.48-.9 1.64-1.85 3.37-1.85 3.6 0 4.27 2.37 4.27 5.46v6.28zM5.34 7.43a2.06 2.06 0 1 1 0-4.13 2.06 2.06 0 0 1 0 4.13zM7.12 20.45H3.55V9h3.57v11.45zM22.22 0H1.77C.79 0 0 .77 0 1.73v20.54C0 23.22.79 24 1.77 24h20.45c.98 0 1.78-.78 1.78-1.73V1.73C24 .77 23.2 0 22.22 0z",
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
colors: { brand: "#4f46e5", brandSoft: "#e0e7ff", paper: "#fafafa" },
|
|
90
|
+
|
|
91
|
+
seo: {
|
|
92
|
+
title: "Halyard — a product studio for ambitious teams.",
|
|
93
|
+
description:
|
|
94
|
+
"Halyard is a Dallas product studio. We design and build web and mobile software end-to-end. We take on a few projects at a time — see how many slots are open this quarter.",
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
hero: {
|
|
98
|
+
tagline: "Product studio · Dallas",
|
|
99
|
+
headline: "We build the products teams bet on.",
|
|
100
|
+
subcopy:
|
|
101
|
+
"Halyard is a small, senior team that designs and ships web and mobile software end-to-end. We take on a handful of projects at a time, so the work — and your launch — stays sharp.",
|
|
102
|
+
ctaLabel: "Start a project",
|
|
103
|
+
secondaryCtaLabel: "See our work",
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
capacity: { label: "Q3 2026", openSlots: 3 },
|
|
107
|
+
|
|
108
|
+
logos: {
|
|
109
|
+
eyebrow: "Trusted by teams at",
|
|
110
|
+
names: ["Northwind", "Lumen", "Foundry", "Atlas", "Cohort", "Vela"],
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
services: {
|
|
114
|
+
eyebrow: "What we do",
|
|
115
|
+
headline: "One team, the whole build.",
|
|
116
|
+
items: [
|
|
117
|
+
{
|
|
118
|
+
icon: "◆",
|
|
119
|
+
title: "Product design",
|
|
120
|
+
body: "Research, flows, and interface design that turns a fuzzy idea into something people understand in seconds.",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
icon: "◇",
|
|
124
|
+
title: "Web & mobile",
|
|
125
|
+
body: "Production engineering across web, iOS, and Android — typed, tested, and built to scale past launch day.",
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
icon: "◈",
|
|
129
|
+
title: "Brand & identity",
|
|
130
|
+
body: "Naming, logo, and a system that makes a young product feel like it's been around — and worth paying for.",
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
icon: "✦",
|
|
134
|
+
title: "Fractional team",
|
|
135
|
+
body: "Embed with your team for a quarter. We plan, build, and hand off — leaving you faster than we found you.",
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
work: {
|
|
141
|
+
eyebrow: "Selected work",
|
|
142
|
+
headline: "A few things we've shipped.",
|
|
143
|
+
items: [
|
|
144
|
+
{
|
|
145
|
+
title: "Ledger",
|
|
146
|
+
client: "Fintech · 0→1",
|
|
147
|
+
summary: "A consumer banking app from first sketch to App Store launch in one quarter.",
|
|
148
|
+
tags: ["Product design", "iOS", "Brand"],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
title: "Atlas Health",
|
|
152
|
+
client: "Healthcare · Platform",
|
|
153
|
+
summary: "Rebuilt a clinical scheduling tool used by 4,000 providers, with zero downtime.",
|
|
154
|
+
tags: ["Web", "Design system"],
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
title: "Cohort",
|
|
158
|
+
client: "B2B SaaS · Rebrand",
|
|
159
|
+
summary: "New identity and marketing site that lifted demo requests 40% in six weeks.",
|
|
160
|
+
tags: ["Brand", "Web"],
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
title: "Vela",
|
|
164
|
+
client: "Logistics · Mobile",
|
|
165
|
+
summary: "A driver app with live routing that cut dispatch calls in half.",
|
|
166
|
+
tags: ["Product design", "Android"],
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
process: {
|
|
172
|
+
eyebrow: "How we work",
|
|
173
|
+
headline: "Senior, hands-on, and fast.",
|
|
174
|
+
steps: [
|
|
175
|
+
{
|
|
176
|
+
title: "Scope",
|
|
177
|
+
body: "A focused kickoff week. We pin down the real problem, the riskiest unknowns, and what a great launch looks like.",
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
title: "Design",
|
|
181
|
+
body: "Clickable, real-data prototypes — not slide decks. You react to something you can use within two weeks.",
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
title: "Build",
|
|
185
|
+
body: "Weekly shipping you can watch. Typed, reviewed code in your repo from day one, no black box.",
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
title: "Launch & hand off",
|
|
189
|
+
body: "We ship it, document it, and leave your team able to run and extend it without us.",
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
team: {
|
|
195
|
+
eyebrow: "Who you'll work with",
|
|
196
|
+
headline: "A small, senior team.",
|
|
197
|
+
members: [
|
|
198
|
+
{ name: "Claire Donovan", role: "Principal, Design" },
|
|
199
|
+
{ name: "Marcus Lee", role: "Principal, Engineering" },
|
|
200
|
+
{ name: "Dana Okafor", role: "Brand & Strategy" },
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
testimonials: {
|
|
205
|
+
eyebrow: "Kind words",
|
|
206
|
+
headline: "Teams we've worked with.",
|
|
207
|
+
items: [
|
|
208
|
+
{
|
|
209
|
+
quote:
|
|
210
|
+
"Halyard shipped in a quarter what our last agency couldn't in a year. Senior people, no hand-offs, no drama.",
|
|
211
|
+
name: "Erin Caldwell",
|
|
212
|
+
role: "CEO, Ledger",
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
quote:
|
|
216
|
+
"They felt like our team, not a vendor. The work was sharp and the launch was the smoothest we've had.",
|
|
217
|
+
name: "Tom Reyes",
|
|
218
|
+
role: "VP Product, Atlas Health",
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
quote:
|
|
222
|
+
"The rebrand paid for itself in two months. We still use the system they built every single day.",
|
|
223
|
+
name: "Sofia Marin",
|
|
224
|
+
role: "Founder, Cohort",
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
contact: {
|
|
230
|
+
eyebrow: "Start a project",
|
|
231
|
+
headline: "Tell us what you're building.",
|
|
232
|
+
subcopy:
|
|
233
|
+
"We take on a few projects at a time. Send a note and we'll reply within two business days — if we're full, we'll tell you straight and point you somewhere good.",
|
|
234
|
+
projectTypes: ["New product (0→1)", "Existing product", "Rebrand", "Not sure yet"],
|
|
235
|
+
budgets: ["$25–50k", "$50–100k", "$100k+", "Let's talk"],
|
|
236
|
+
confirmationMessage:
|
|
237
|
+
"Thanks — your note's in. We'll get back to you within two business days.",
|
|
238
|
+
},
|
|
239
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
// `cn` — the shadcn class merger. clsx resolves conditional/array class
|
|
5
|
+
// inputs; tailwind-merge then dedupes conflicting Tailwind utilities so
|
|
6
|
+
// the last one wins (e.g. `cn("px-2", "px-4")` → "px-4"). Every shadcn
|
|
7
|
+
// component routes its className through this.
|
|
8
|
+
export function cn(...inputs: ClassValue[]) {
|
|
9
|
+
return twMerge(clsx(inputs));
|
|
10
|
+
}
|