@pylonsync/create-pylon 0.3.274 → 0.3.276
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 +1440 -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 +249 -0
- package/templates/agency/app/robots.ts +12 -0
- package/templates/agency/app/seeder.tsx +26 -0
- package/templates/agency/app/sitemap.ts +9 -0
- package/templates/agency/app/work/[slug]/page.tsx +182 -0
- package/templates/agency/app/work/page.tsx +83 -0
- package/templates/agency/app.ts +284 -0
- package/templates/agency/components/marketing.tsx +187 -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/clientsForOwner.ts +27 -0
- package/templates/agency/functions/declineInquiry.ts +41 -0
- package/templates/agency/functions/deleteClient.ts +27 -0
- package/templates/agency/functions/deleteInvoice.ts +19 -0
- package/templates/agency/functions/deleteProject.ts +20 -0
- package/templates/agency/functions/inquiriesForOwner.ts +31 -0
- package/templates/agency/functions/invoicesForOwner.ts +27 -0
- package/templates/agency/functions/seedCapacity.ts +26 -0
- package/templates/agency/functions/seedProjects.ts +41 -0
- package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
- package/templates/agency/functions/setCapacity.ts +32 -0
- package/templates/agency/functions/setInvoiceStatus.ts +27 -0
- package/templates/agency/functions/setProjectFlags.ts +35 -0
- package/templates/agency/functions/submitInquiry.ts +55 -0
- package/templates/agency/functions/upsertClient.ts +73 -0
- package/templates/agency/functions/upsertInvoice.ts +113 -0
- package/templates/agency/functions/upsertProject.ts +97 -0
- package/templates/agency/gitignore +10 -0
- package/templates/agency/lib/agency.ts +189 -0
- package/templates/agency/lib/invoice-pdf.tsx +174 -0
- package/templates/agency/lib/owner.ts +26 -0
- package/templates/agency/lib/site.config.ts +418 -0
- package/templates/agency/lib/utils.ts +10 -0
- package/templates/agency/package.json +35 -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 +727 -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/functions/deleteConversation.ts +33 -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 +357 -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,113 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import { lineItemsTotal, type ClientRow, type InvoiceLineItem, type ProjectRow } from "../lib/agency";
|
|
4
|
+
|
|
5
|
+
// upsertInvoice — owner-only. Create a bill or edit one (pass `id`). The client
|
|
6
|
+
// is referenced by id and must exist; we denormalize its name onto the invoice
|
|
7
|
+
// (`clientName`) so the list renders without a join, and so the bill keeps the
|
|
8
|
+
// name it was issued under even if the contact is renamed later. An optional
|
|
9
|
+
// project link works the same way (`projectTitle`).
|
|
10
|
+
//
|
|
11
|
+
// `lineItems` is the source of truth for the amount: when present we store it
|
|
12
|
+
// (JSON) and compute `amountCents = Σ quantity × unitCents` server-side, so the
|
|
13
|
+
// total can never disagree with the breakdown the client sent. When omitted we
|
|
14
|
+
// fall back to the explicit `amountCents` (a one-line bill). Amounts are cents.
|
|
15
|
+
type Args = {
|
|
16
|
+
id?: string;
|
|
17
|
+
number: string;
|
|
18
|
+
clientId: string;
|
|
19
|
+
projectId?: string;
|
|
20
|
+
lineItems?: InvoiceLineItem[];
|
|
21
|
+
amountCents: number;
|
|
22
|
+
status?: string;
|
|
23
|
+
issuedAt?: string;
|
|
24
|
+
dueAt?: string;
|
|
25
|
+
notes?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const STATUSES = new Set(["draft", "sent", "paid", "overdue"]);
|
|
29
|
+
const clip = (s: string | undefined, max: number): string | null =>
|
|
30
|
+
s != null && s.trim().length > 0 ? s.trim().slice(0, max) : null;
|
|
31
|
+
|
|
32
|
+
export default mutation<Args, { ok: boolean; id: string }>({
|
|
33
|
+
auth: "user",
|
|
34
|
+
args: {
|
|
35
|
+
id: v.optional(v.string()),
|
|
36
|
+
number: v.string(),
|
|
37
|
+
clientId: v.string(),
|
|
38
|
+
projectId: v.optional(v.string()),
|
|
39
|
+
lineItems: v.optional(
|
|
40
|
+
v.array(
|
|
41
|
+
v.object({ description: v.string(), quantity: v.number(), unitCents: v.int() }),
|
|
42
|
+
),
|
|
43
|
+
),
|
|
44
|
+
amountCents: v.int(),
|
|
45
|
+
status: v.optional(v.string()),
|
|
46
|
+
issuedAt: v.optional(v.string()),
|
|
47
|
+
dueAt: v.optional(v.string()),
|
|
48
|
+
notes: v.optional(v.string()),
|
|
49
|
+
},
|
|
50
|
+
async handler(ctx, args) {
|
|
51
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
52
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
53
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage invoices.");
|
|
54
|
+
}
|
|
55
|
+
const number = args.number.trim();
|
|
56
|
+
if (number.length < 1 || number.length > 40) {
|
|
57
|
+
throw ctx.error("INVALID_ARGS", "An invoice number is required.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const client = (await ctx.db.get("Client", args.clientId)) as ClientRow | null;
|
|
61
|
+
if (!client) throw ctx.error("INVALID_ARGS", "Pick a client for this invoice.");
|
|
62
|
+
|
|
63
|
+
let projectId: string | null = null;
|
|
64
|
+
let projectTitle: string | null = null;
|
|
65
|
+
if (args.projectId) {
|
|
66
|
+
const project = (await ctx.db.get("Project", args.projectId)) as ProjectRow | null;
|
|
67
|
+
if (project) {
|
|
68
|
+
projectId = project.id;
|
|
69
|
+
projectTitle = project.title;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Normalize line items; when present they define the total.
|
|
74
|
+
const items: InvoiceLineItem[] = (args.lineItems ?? [])
|
|
75
|
+
.map((it) => ({
|
|
76
|
+
description: (it.description ?? "").trim().slice(0, 300),
|
|
77
|
+
quantity: Math.max(0, Number(it.quantity) || 0),
|
|
78
|
+
unitCents: Math.max(0, Math.trunc(Number(it.unitCents) || 0)),
|
|
79
|
+
}))
|
|
80
|
+
.filter((it) => it.description.length > 0 || it.unitCents > 0);
|
|
81
|
+
|
|
82
|
+
const amountCents =
|
|
83
|
+
items.length > 0 ? lineItemsTotal(items) : Math.max(0, Math.trunc(args.amountCents || 0));
|
|
84
|
+
const status = STATUSES.has((args.status ?? "").trim()) ? args.status!.trim() : "draft";
|
|
85
|
+
|
|
86
|
+
const patch = {
|
|
87
|
+
number,
|
|
88
|
+
clientId: client.id,
|
|
89
|
+
clientName: client.name,
|
|
90
|
+
projectId,
|
|
91
|
+
projectTitle,
|
|
92
|
+
lineItems: items.length > 0 ? JSON.stringify(items) : null,
|
|
93
|
+
amountCents,
|
|
94
|
+
status,
|
|
95
|
+
issuedAt: clip(args.issuedAt, 20),
|
|
96
|
+
dueAt: clip(args.dueAt, 20),
|
|
97
|
+
notes: clip(args.notes, 2000),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (args.id) {
|
|
101
|
+
const existing = await ctx.db.get("Invoice", args.id);
|
|
102
|
+
if (!existing) throw ctx.error("NOT_FOUND", "Invoice not found.");
|
|
103
|
+
await ctx.db.unsafe.update("Invoice", args.id, patch);
|
|
104
|
+
return { ok: true, id: args.id };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const id = await ctx.db.unsafe.insert("Invoice", {
|
|
108
|
+
...patch,
|
|
109
|
+
createdAt: new Date().toISOString(),
|
|
110
|
+
});
|
|
111
|
+
return { ok: true, id };
|
|
112
|
+
},
|
|
113
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { mutation, v } from "@pylonsync/functions";
|
|
2
|
+
import { emailMatchesOwner } from "../lib/owner";
|
|
3
|
+
import { slugify, type ProjectRow } from "../lib/agency";
|
|
4
|
+
|
|
5
|
+
// upsertProject — owner-only. Create a new case study or edit an existing one
|
|
6
|
+
// (pass `id` to update). Projects are public-read, so the change shows up on the
|
|
7
|
+
// dashboard's live `db.useQuery("Project")` immediately, and on the marketing
|
|
8
|
+
// site on its next render. The slug is derived from the title (or an explicit
|
|
9
|
+
// slug) and made unique — a unique index backs it, so we resolve collisions
|
|
10
|
+
// here rather than letting the insert fail.
|
|
11
|
+
type Args = {
|
|
12
|
+
id?: string;
|
|
13
|
+
title: string;
|
|
14
|
+
slug?: string;
|
|
15
|
+
client?: string;
|
|
16
|
+
summary?: string;
|
|
17
|
+
year?: string;
|
|
18
|
+
tags?: string;
|
|
19
|
+
selected?: boolean;
|
|
20
|
+
published?: boolean;
|
|
21
|
+
order?: number;
|
|
22
|
+
challenge?: string;
|
|
23
|
+
approach?: string;
|
|
24
|
+
outcome?: string;
|
|
25
|
+
liveUrl?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const clip = (s: string | undefined, max: number): string | null =>
|
|
29
|
+
s != null && s.trim().length > 0 ? s.trim().slice(0, max) : null;
|
|
30
|
+
|
|
31
|
+
export default mutation<Args, { ok: boolean; id: string; slug: string }>({
|
|
32
|
+
auth: "user",
|
|
33
|
+
args: {
|
|
34
|
+
id: v.optional(v.string()),
|
|
35
|
+
title: v.string(),
|
|
36
|
+
slug: v.optional(v.string()),
|
|
37
|
+
client: v.optional(v.string()),
|
|
38
|
+
summary: v.optional(v.string()),
|
|
39
|
+
year: v.optional(v.string()),
|
|
40
|
+
tags: v.optional(v.string()),
|
|
41
|
+
selected: v.optional(v.boolean()),
|
|
42
|
+
published: v.optional(v.boolean()),
|
|
43
|
+
order: v.optional(v.int()),
|
|
44
|
+
challenge: v.optional(v.string()),
|
|
45
|
+
approach: v.optional(v.string()),
|
|
46
|
+
outcome: v.optional(v.string()),
|
|
47
|
+
liveUrl: v.optional(v.string()),
|
|
48
|
+
},
|
|
49
|
+
async handler(ctx, args) {
|
|
50
|
+
const me = await ctx.db.get("User", ctx.auth.userId);
|
|
51
|
+
if (!emailMatchesOwner(me?.email as string | undefined, ctx.env.PYLON_OWNER_EMAIL)) {
|
|
52
|
+
throw ctx.error("POLICY_DENIED", "Only the owner can manage projects.");
|
|
53
|
+
}
|
|
54
|
+
const title = args.title.trim();
|
|
55
|
+
if (title.length < 1 || title.length > 120) {
|
|
56
|
+
throw ctx.error("INVALID_ARGS", "A project title is required (up to 120 chars).");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await ctx.db.advisoryLock("agency_projects");
|
|
60
|
+
const all = (await ctx.db.unsafe.list("Project")) as unknown as ProjectRow[];
|
|
61
|
+
|
|
62
|
+
// Resolve a unique slug (skip the row being edited).
|
|
63
|
+
const base = slugify(args.slug || title) || "project";
|
|
64
|
+
let slug = base;
|
|
65
|
+
let n = 2;
|
|
66
|
+
while (all.some((p) => p.slug === slug && p.id !== args.id)) slug = `${base}-${n++}`;
|
|
67
|
+
|
|
68
|
+
const patch = {
|
|
69
|
+
title,
|
|
70
|
+
slug,
|
|
71
|
+
client: clip(args.client, 120) ?? "",
|
|
72
|
+
summary: clip(args.summary, 300) ?? "",
|
|
73
|
+
year: clip(args.year, 12),
|
|
74
|
+
tags: clip(args.tags, 200),
|
|
75
|
+
selected: args.selected === true,
|
|
76
|
+
published: args.published !== false, // default published
|
|
77
|
+
order: Math.trunc(args.order ?? 0),
|
|
78
|
+
challenge: clip(args.challenge, 2000),
|
|
79
|
+
approach: clip(args.approach, 2000),
|
|
80
|
+
outcome: clip(args.outcome, 2000),
|
|
81
|
+
liveUrl: clip(args.liveUrl, 400),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (args.id) {
|
|
85
|
+
const existing = all.find((p) => p.id === args.id);
|
|
86
|
+
if (!existing) throw ctx.error("NOT_FOUND", "Project not found.");
|
|
87
|
+
await ctx.db.unsafe.update("Project", args.id, patch);
|
|
88
|
+
return { ok: true, id: args.id, slug };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const id = await ctx.db.unsafe.insert("Project", {
|
|
92
|
+
...patch,
|
|
93
|
+
createdAt: new Date().toISOString(),
|
|
94
|
+
});
|
|
95
|
+
return { ok: true, id, slug };
|
|
96
|
+
},
|
|
97
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Shared agency types. The Inquiry / Client / Invoice rows are what the owner
|
|
2
|
+
// dashboard sees (with PII + money); the client imports only the types, never
|
|
3
|
+
// server code. Project is public, so it's read on both the marketing site and
|
|
4
|
+
// the dashboard.
|
|
5
|
+
|
|
6
|
+
export interface InquiryRow {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
email: string;
|
|
10
|
+
company?: string | null;
|
|
11
|
+
projectType?: string | null;
|
|
12
|
+
budget?: string | null;
|
|
13
|
+
message?: string | null;
|
|
14
|
+
status: string; // "new" | "booked" | "declined"
|
|
15
|
+
createdAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// inquiriesForOwner returns a discriminated result rather than throwing on a
|
|
19
|
+
// non-owner (a query has no `ctx.error`; a bare throw becomes a stripped
|
|
20
|
+
// HANDLER_ERROR). A non-owner gets `{ authorized: false }` and NO data. The
|
|
21
|
+
// other owner-gated queries (clientsForOwner / invoicesForOwner) follow the
|
|
22
|
+
// same shape — see OwnerResult below.
|
|
23
|
+
export type OwnerInquiriesResult =
|
|
24
|
+
| { authorized: true; inquiries: InquiryRow[] }
|
|
25
|
+
| { authorized: false };
|
|
26
|
+
|
|
27
|
+
// The public, PII-free capacity the landing page reads live.
|
|
28
|
+
export interface CapacityData {
|
|
29
|
+
label: string; // booking window, e.g. "Q3 2026"
|
|
30
|
+
openSlots: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A portfolio piece + case study. Public — read on the marketing site AND in
|
|
34
|
+
// the dashboard. `selected` features it on the homepage; `published` toggles
|
|
35
|
+
// whether the public site shows it (drafts stay owner-only).
|
|
36
|
+
export interface ProjectRow {
|
|
37
|
+
id: string;
|
|
38
|
+
title: string;
|
|
39
|
+
slug: string;
|
|
40
|
+
client: string;
|
|
41
|
+
summary: string;
|
|
42
|
+
year?: string | null;
|
|
43
|
+
tags?: string | null; // comma-separated
|
|
44
|
+
selected: boolean;
|
|
45
|
+
published: boolean;
|
|
46
|
+
order: number;
|
|
47
|
+
challenge?: string | null;
|
|
48
|
+
approach?: string | null;
|
|
49
|
+
outcome?: string | null;
|
|
50
|
+
liveUrl?: string | null;
|
|
51
|
+
createdAt: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// A CRM contact (owner-only — PII).
|
|
55
|
+
export interface ClientRow {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
company?: string | null;
|
|
59
|
+
email?: string | null;
|
|
60
|
+
phone?: string | null;
|
|
61
|
+
status: string; // "prospect" | "active" | "past"
|
|
62
|
+
notes?: string | null;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One line on an invoice. `unitCents` is the per-unit price in integer cents;
|
|
67
|
+
// the line total is `quantity * unitCents`.
|
|
68
|
+
export interface InvoiceLineItem {
|
|
69
|
+
description: string;
|
|
70
|
+
quantity: number;
|
|
71
|
+
unitCents: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A bill tied to a client, + optional project (owner-only — money + PII).
|
|
75
|
+
// `amountCents` is the integer-cents total; `lineItems` is the JSON-encoded
|
|
76
|
+
// breakdown (parse with `parseLineItems`).
|
|
77
|
+
export interface InvoiceRow {
|
|
78
|
+
id: string;
|
|
79
|
+
number: string;
|
|
80
|
+
clientId: string;
|
|
81
|
+
clientName: string;
|
|
82
|
+
projectId?: string | null;
|
|
83
|
+
projectTitle?: string | null;
|
|
84
|
+
lineItems?: string | null; // JSON array of InvoiceLineItem
|
|
85
|
+
amountCents: number;
|
|
86
|
+
status: string; // "draft" | "sent" | "paid" | "overdue"
|
|
87
|
+
issuedAt?: string | null;
|
|
88
|
+
dueAt?: string | null;
|
|
89
|
+
notes?: string | null;
|
|
90
|
+
createdAt: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Owner-gated queries that carry private rows use the same discriminated shape
|
|
94
|
+
// as inquiries: `{ authorized: false }` for a non-owner, otherwise the data.
|
|
95
|
+
export type OwnerClientsResult =
|
|
96
|
+
| { authorized: true; clients: ClientRow[] }
|
|
97
|
+
| { authorized: false };
|
|
98
|
+
|
|
99
|
+
export type OwnerInvoicesResult =
|
|
100
|
+
| { authorized: true; invoices: InvoiceRow[] }
|
|
101
|
+
| { authorized: false };
|
|
102
|
+
|
|
103
|
+
/* ------------------------------ helpers ------------------------------ */
|
|
104
|
+
|
|
105
|
+
// Split a comma-separated tag string into trimmed, non-empty tags.
|
|
106
|
+
export function parseTags(tags?: string | null): string[] {
|
|
107
|
+
return (tags ?? "")
|
|
108
|
+
.split(",")
|
|
109
|
+
.map((t) => t.trim())
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Format integer cents as a currency string ("$1,500.00"). Whole-dollar amounts
|
|
114
|
+
// drop the cents ("$1,500").
|
|
115
|
+
export function money(cents: number): string {
|
|
116
|
+
const dollars = (cents || 0) / 100;
|
|
117
|
+
const whole = Number.isInteger(dollars);
|
|
118
|
+
return dollars.toLocaleString("en-US", {
|
|
119
|
+
style: "currency",
|
|
120
|
+
currency: "USD",
|
|
121
|
+
minimumFractionDigits: whole ? 0 : 2,
|
|
122
|
+
maximumFractionDigits: 2,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Parse the JSON line-item blob off an invoice into a clean, typed array.
|
|
127
|
+
// Tolerant: bad JSON or missing fields yield an empty list / zeroed fields
|
|
128
|
+
// rather than throwing, so a malformed row never crashes the dashboard or PDF.
|
|
129
|
+
export function parseLineItems(raw?: string | null): InvoiceLineItem[] {
|
|
130
|
+
if (!raw) return [];
|
|
131
|
+
try {
|
|
132
|
+
const parsed = JSON.parse(raw);
|
|
133
|
+
if (!Array.isArray(parsed)) return [];
|
|
134
|
+
return parsed.map((it) => ({
|
|
135
|
+
description: String(it?.description ?? ""),
|
|
136
|
+
quantity: Number(it?.quantity) || 0,
|
|
137
|
+
unitCents: Math.trunc(Number(it?.unitCents) || 0),
|
|
138
|
+
}));
|
|
139
|
+
} catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Total of a line-item list, in integer cents.
|
|
145
|
+
export function lineItemsTotal(items: InvoiceLineItem[]): number {
|
|
146
|
+
return items.reduce((sum, it) => sum + Math.round(it.quantity * it.unitCents), 0);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Slugify a project title for its /work/[slug] URL.
|
|
150
|
+
export function slugify(s: string): string {
|
|
151
|
+
return s
|
|
152
|
+
.toLowerCase()
|
|
153
|
+
.trim()
|
|
154
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
155
|
+
.replace(/^-+|-+$/g, "")
|
|
156
|
+
.slice(0, 60);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A normalized project shape the public pages render — tags already split, the
|
|
160
|
+
// storage flags (selected/published/order) dropped. Built from a DB row
|
|
161
|
+
// (`viewFromRow`) or, before the portfolio is seeded, from a config case study,
|
|
162
|
+
// so the marketing pages render identically either way.
|
|
163
|
+
export interface ProjectView {
|
|
164
|
+
slug: string;
|
|
165
|
+
title: string;
|
|
166
|
+
client: string;
|
|
167
|
+
summary: string;
|
|
168
|
+
year?: string | null;
|
|
169
|
+
tags: string[];
|
|
170
|
+
challenge?: string | null;
|
|
171
|
+
approach?: string | null;
|
|
172
|
+
outcome?: string | null;
|
|
173
|
+
liveUrl?: string | null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function viewFromRow(r: ProjectRow): ProjectView {
|
|
177
|
+
return {
|
|
178
|
+
slug: r.slug,
|
|
179
|
+
title: r.title,
|
|
180
|
+
client: r.client,
|
|
181
|
+
summary: r.summary,
|
|
182
|
+
year: r.year,
|
|
183
|
+
tags: parseTags(r.tags),
|
|
184
|
+
challenge: r.challenge,
|
|
185
|
+
approach: r.approach,
|
|
186
|
+
outcome: r.outcome,
|
|
187
|
+
liveUrl: r.liveUrl,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Document, Page, View, Text, StyleSheet, pdf } from "@react-pdf/renderer";
|
|
3
|
+
import { money, type InvoiceLineItem } from "./agency";
|
|
4
|
+
|
|
5
|
+
// Invoice → PDF, with @react-pdf/renderer (the React-to-PDF renderer — note
|
|
6
|
+
// `react-pdf` proper is a *viewer* for existing PDFs; this is the generator).
|
|
7
|
+
//
|
|
8
|
+
// This module is imported DYNAMICALLY from the dashboard's "Download PDF" click
|
|
9
|
+
// handler (`await import("@/lib/invoice-pdf")`), never during render — so the
|
|
10
|
+
// ~1MB renderer + this document never touch SSR or the initial client bundle;
|
|
11
|
+
// it loads only when the owner actually exports a bill. The PDF mirrors the
|
|
12
|
+
// on-screen invoice view so what you see is what you download.
|
|
13
|
+
|
|
14
|
+
export interface InvoicePdfData {
|
|
15
|
+
number: string;
|
|
16
|
+
status: string;
|
|
17
|
+
issuedAt?: string | null;
|
|
18
|
+
dueAt?: string | null;
|
|
19
|
+
notes?: string | null;
|
|
20
|
+
projectTitle?: string | null;
|
|
21
|
+
items: InvoiceLineItem[];
|
|
22
|
+
amountCents: number;
|
|
23
|
+
billTo: { name: string; company?: string | null; email?: string | null };
|
|
24
|
+
studio: {
|
|
25
|
+
name: string;
|
|
26
|
+
addressLines: string[];
|
|
27
|
+
paymentTerms: string;
|
|
28
|
+
footerNote: string;
|
|
29
|
+
brandColor: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Build (and return) the invoice PDF as a Blob the caller can download.
|
|
34
|
+
export async function buildInvoiceBlob(data: InvoicePdfData): Promise<Blob> {
|
|
35
|
+
return await pdf(<InvoiceDocument data={data} />).toBlob();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const STATUS_COLOR: Record<string, string> = {
|
|
39
|
+
draft: "#71717a",
|
|
40
|
+
sent: "#2563eb",
|
|
41
|
+
paid: "#16a34a",
|
|
42
|
+
overdue: "#dc2626",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function InvoiceDocument({ data }: { data: InvoicePdfData }) {
|
|
46
|
+
const { studio, billTo } = data;
|
|
47
|
+
const s = makeStyles(studio.brandColor);
|
|
48
|
+
// A bill with no explicit line items still prints one line for its total.
|
|
49
|
+
const items =
|
|
50
|
+
data.items.length > 0
|
|
51
|
+
? data.items
|
|
52
|
+
: [{ description: "Services", quantity: 1, unitCents: data.amountCents }];
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Document title={`Invoice ${data.number}`} author={studio.name}>
|
|
56
|
+
<Page size="A4" style={s.page}>
|
|
57
|
+
{/* Header */}
|
|
58
|
+
<View style={s.header}>
|
|
59
|
+
<View>
|
|
60
|
+
<Text style={s.studioName}>{studio.name}</Text>
|
|
61
|
+
{studio.addressLines.map((l, i) => (
|
|
62
|
+
<Text key={i} style={s.muted}>
|
|
63
|
+
{l}
|
|
64
|
+
</Text>
|
|
65
|
+
))}
|
|
66
|
+
</View>
|
|
67
|
+
<View style={s.headerRight}>
|
|
68
|
+
<Text style={s.invoiceLabel}>INVOICE</Text>
|
|
69
|
+
<Text style={s.invoiceNumber}>{data.number}</Text>
|
|
70
|
+
<Text style={[s.status, { color: STATUS_COLOR[data.status] ?? "#71717a" }]}>
|
|
71
|
+
{data.status.toUpperCase()}
|
|
72
|
+
</Text>
|
|
73
|
+
</View>
|
|
74
|
+
</View>
|
|
75
|
+
|
|
76
|
+
<View style={s.rule} />
|
|
77
|
+
|
|
78
|
+
{/* Bill-to + meta */}
|
|
79
|
+
<View style={s.cols}>
|
|
80
|
+
<View style={s.col}>
|
|
81
|
+
<Text style={s.sectionLabel}>BILL TO</Text>
|
|
82
|
+
<Text style={s.strong}>{billTo.name}</Text>
|
|
83
|
+
{billTo.company ? <Text style={s.muted}>{billTo.company}</Text> : null}
|
|
84
|
+
{billTo.email ? <Text style={s.muted}>{billTo.email}</Text> : null}
|
|
85
|
+
</View>
|
|
86
|
+
<View style={s.colRight}>
|
|
87
|
+
{data.projectTitle ? <Meta s={s} label="Project" value={data.projectTitle} /> : null}
|
|
88
|
+
{data.issuedAt ? <Meta s={s} label="Issued" value={data.issuedAt} /> : null}
|
|
89
|
+
{data.dueAt ? <Meta s={s} label="Due" value={data.dueAt} /> : null}
|
|
90
|
+
<Meta s={s} label="Terms" value={studio.paymentTerms} />
|
|
91
|
+
</View>
|
|
92
|
+
</View>
|
|
93
|
+
|
|
94
|
+
{/* Line items */}
|
|
95
|
+
<View style={s.table}>
|
|
96
|
+
<View style={s.tableHead}>
|
|
97
|
+
<Text style={[s.cell, s.cDesc, s.headCell]}>Description</Text>
|
|
98
|
+
<Text style={[s.cell, s.cQty, s.headCell]}>Qty</Text>
|
|
99
|
+
<Text style={[s.cell, s.cUnit, s.headCell]}>Unit</Text>
|
|
100
|
+
<Text style={[s.cell, s.cAmt, s.headCell]}>Amount</Text>
|
|
101
|
+
</View>
|
|
102
|
+
{items.map((it, i) => (
|
|
103
|
+
<View key={i} style={s.tableRow}>
|
|
104
|
+
<Text style={[s.cell, s.cDesc]}>{it.description}</Text>
|
|
105
|
+
<Text style={[s.cell, s.cQty]}>{it.quantity}</Text>
|
|
106
|
+
<Text style={[s.cell, s.cUnit]}>{money(it.unitCents)}</Text>
|
|
107
|
+
<Text style={[s.cell, s.cAmt]}>{money(Math.round(it.quantity * it.unitCents))}</Text>
|
|
108
|
+
</View>
|
|
109
|
+
))}
|
|
110
|
+
<View style={s.totalRow}>
|
|
111
|
+
<Text style={s.totalLabel}>Total due</Text>
|
|
112
|
+
<Text style={s.totalValue}>{money(data.amountCents)}</Text>
|
|
113
|
+
</View>
|
|
114
|
+
</View>
|
|
115
|
+
|
|
116
|
+
{data.notes ? (
|
|
117
|
+
<View style={s.notes}>
|
|
118
|
+
<Text style={s.sectionLabel}>NOTES</Text>
|
|
119
|
+
<Text style={s.muted}>{data.notes}</Text>
|
|
120
|
+
</View>
|
|
121
|
+
) : null}
|
|
122
|
+
|
|
123
|
+
<Text style={s.footer} fixed>
|
|
124
|
+
{studio.footerNote}
|
|
125
|
+
</Text>
|
|
126
|
+
</Page>
|
|
127
|
+
</Document>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function Meta({ s, label, value }: { s: ReturnType<typeof makeStyles>; label: string; value: string }) {
|
|
132
|
+
return (
|
|
133
|
+
<View style={s.metaRow}>
|
|
134
|
+
<Text style={s.metaLabel}>{label}</Text>
|
|
135
|
+
<Text style={s.metaValue}>{value}</Text>
|
|
136
|
+
</View>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function makeStyles(brand: string) {
|
|
141
|
+
return StyleSheet.create({
|
|
142
|
+
page: { padding: 48, fontSize: 10, color: "#27272a", fontFamily: "Helvetica", lineHeight: 1.4 },
|
|
143
|
+
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" },
|
|
144
|
+
studioName: { fontSize: 18, fontFamily: "Helvetica-Bold", color: "#18181b", marginBottom: 4 },
|
|
145
|
+
headerRight: { alignItems: "flex-end" },
|
|
146
|
+
invoiceLabel: { fontSize: 11, letterSpacing: 2, color: brand, fontFamily: "Helvetica-Bold" },
|
|
147
|
+
invoiceNumber: { fontSize: 13, marginTop: 2, fontFamily: "Helvetica-Bold", color: "#18181b" },
|
|
148
|
+
status: { fontSize: 9, marginTop: 4, fontFamily: "Helvetica-Bold", letterSpacing: 1 },
|
|
149
|
+
muted: { color: "#71717a" },
|
|
150
|
+
rule: { borderBottomWidth: 1, borderBottomColor: "#e4e4e7", marginVertical: 18 },
|
|
151
|
+
cols: { flexDirection: "row", justifyContent: "space-between", marginBottom: 24 },
|
|
152
|
+
col: { width: "55%" },
|
|
153
|
+
colRight: { width: "40%" },
|
|
154
|
+
sectionLabel: { fontSize: 8, letterSpacing: 1.5, color: "#a1a1aa", fontFamily: "Helvetica-Bold", marginBottom: 5 },
|
|
155
|
+
strong: { fontFamily: "Helvetica-Bold", color: "#18181b", fontSize: 11 },
|
|
156
|
+
metaRow: { flexDirection: "row", justifyContent: "space-between", marginBottom: 3 },
|
|
157
|
+
metaLabel: { color: "#a1a1aa" },
|
|
158
|
+
metaValue: { color: "#27272a", fontFamily: "Helvetica-Bold" },
|
|
159
|
+
table: { marginTop: 4 },
|
|
160
|
+
tableHead: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: "#27272a", paddingBottom: 6 },
|
|
161
|
+
headCell: { fontFamily: "Helvetica-Bold", color: "#18181b", fontSize: 9, letterSpacing: 0.5 },
|
|
162
|
+
tableRow: { flexDirection: "row", borderBottomWidth: 1, borderBottomColor: "#f4f4f5", paddingVertical: 8 },
|
|
163
|
+
cell: { fontSize: 10 },
|
|
164
|
+
cDesc: { width: "52%", paddingRight: 8 },
|
|
165
|
+
cQty: { width: "12%", textAlign: "right" },
|
|
166
|
+
cUnit: { width: "18%", textAlign: "right" },
|
|
167
|
+
cAmt: { width: "18%", textAlign: "right" },
|
|
168
|
+
totalRow: { flexDirection: "row", justifyContent: "flex-end", marginTop: 12, paddingTop: 4 },
|
|
169
|
+
totalLabel: { fontSize: 11, fontFamily: "Helvetica-Bold", color: "#18181b", marginRight: 16 },
|
|
170
|
+
totalValue: { fontSize: 14, fontFamily: "Helvetica-Bold", color: brand, minWidth: 90, textAlign: "right" },
|
|
171
|
+
notes: { marginTop: 28 },
|
|
172
|
+
footer: { position: "absolute", bottom: 36, left: 48, right: 48, fontSize: 9, color: "#a1a1aa", textAlign: "center" },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
@@ -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
|
+
}
|