@fullgrainsoftware/portal 0.9.0
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/LICENSE +21 -0
- package/README.md +242 -0
- package/bin/fullgrain.mjs +3 -0
- package/dist/bookings.d.ts +169 -0
- package/dist/bookings.js +96 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +116 -0
- package/dist/cms-client.d.ts +9 -0
- package/dist/cms-client.js +38 -0
- package/dist/cms-types.d.ts +99 -0
- package/dist/cms-types.js +1 -0
- package/dist/commerce.d.ts +111 -0
- package/dist/commerce.js +91 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +8 -0
- package/dist/inquiries.d.ts +38 -0
- package/dist/inquiries.js +52 -0
- package/dist/manifest.d.ts +297 -0
- package/dist/manifest.js +102 -0
- package/dist/preview.d.ts +49 -0
- package/dist/preview.js +165 -0
- package/dist/reader.d.ts +23 -0
- package/dist/reader.js +82 -0
- package/dist/reviews.d.ts +2 -0
- package/dist/reviews.js +13 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Jessen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# @fullgrainsoftware/portal
|
|
2
|
+
|
|
3
|
+
Site SDK for the [Fullgrain Portal](https://www.abdevpanel.com) CMS: declare your
|
|
4
|
+
site's structure in a typed manifest, read CMS-managed content in your pages,
|
|
5
|
+
and sync structural changes with one command.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @fullgrainsoftware/portal
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## How it fits together
|
|
12
|
+
|
|
13
|
+
- **The manifest owns structure.** `cms.manifest.ts` in your repo declares
|
|
14
|
+
pages, sections, and fields.
|
|
15
|
+
- **The CMS owns content.** Editors change copy and images in the Fullgrain
|
|
16
|
+
Portal; your site fetches that content at render time.
|
|
17
|
+
- **Seeds run once.** The `seed` blocks in the manifest fill a section the
|
|
18
|
+
first time it syncs — after that, the CMS content is the source of truth
|
|
19
|
+
and drifts from the manifest by design.
|
|
20
|
+
|
|
21
|
+
## Declare your site
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// cms.manifest.ts
|
|
25
|
+
import { defineSite } from "@fullgrainsoftware/portal";
|
|
26
|
+
|
|
27
|
+
export default defineSite({
|
|
28
|
+
alias: "my_site",
|
|
29
|
+
pages: [
|
|
30
|
+
{
|
|
31
|
+
slug: "/",
|
|
32
|
+
title: "Home",
|
|
33
|
+
sections: [
|
|
34
|
+
{
|
|
35
|
+
key: "hero",
|
|
36
|
+
title: "Hero",
|
|
37
|
+
kind: "singleton",
|
|
38
|
+
fields: {
|
|
39
|
+
heading: { label: "Heading", type: "text" },
|
|
40
|
+
subheading: { label: "Subheading", type: "text" },
|
|
41
|
+
},
|
|
42
|
+
seed: [{ name: "Hero", values: { heading: "Welcome" } }],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Read content in your pages
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
import { fetchPublicSiteData, readSiteContent, cmsItemProps } from "@fullgrainsoftware/portal";
|
|
54
|
+
import manifest from "../cms.manifest";
|
|
55
|
+
|
|
56
|
+
// fetchPublicSiteData takes the site's CMS id (a UUID from your Fullgrain Software
|
|
57
|
+
// contact), NOT the manifest alias. It returns { website }, so unwrap it before
|
|
58
|
+
// passing to readSiteContent — otherwise every section silently reads as seed.
|
|
59
|
+
const data = await fetchPublicSiteData(process.env.WEBSITE_ID!);
|
|
60
|
+
const content = readSiteContent(manifest, data.website);
|
|
61
|
+
|
|
62
|
+
content.hero.heading; // typed field access
|
|
63
|
+
|
|
64
|
+
// Collection sections are arrays; spread cmsItemProps(item) onto each item's
|
|
65
|
+
// wrapper for live-preview click-to-edit.
|
|
66
|
+
content.showcase.map((item) => (
|
|
67
|
+
<div key={item._cms.id} {...cmsItemProps(item)}>
|
|
68
|
+
{item.name}
|
|
69
|
+
</div>
|
|
70
|
+
));
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`fetchPublicSiteData` targets the Fullgrain Portal by default; override with the
|
|
74
|
+
`AB_CMS_URL` environment variable or `{ baseUrl }`.
|
|
75
|
+
|
|
76
|
+
## Sync structural changes
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
npx fullgrain sync --dry-run # show what would change
|
|
80
|
+
npx fullgrain sync # apply schema changes; seed only brand-new sections
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Requires `AB_CMS_API_KEY` in the environment (ask your Fullgrain Software contact).
|
|
84
|
+
The dry-run reports existing content as `skipped: seed` and CMS-only content
|
|
85
|
+
as orphans — both are normal, not errors.
|
|
86
|
+
|
|
87
|
+
## Commerce (Lite Commerce stores)
|
|
88
|
+
|
|
89
|
+
For sites whose store is enabled in the panel. **Server-only** — the API key
|
|
90
|
+
is a secret; `createCommerce` throws if constructed in the browser.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
import { createCommerce } from "@fullgrainsoftware/portal";
|
|
94
|
+
|
|
95
|
+
const commerce = createCommerce({ apiKey: process.env.COMMERCE_API_KEY! });
|
|
96
|
+
|
|
97
|
+
const { store, products } = await commerce.listProducts();
|
|
98
|
+
const { product } = await commerce.getProduct("demo-tee");
|
|
99
|
+
const result = await commerce.createCheckout({
|
|
100
|
+
items: [{ variantId: product!.variants[0].id, quantity: 1 }],
|
|
101
|
+
successUrl: "https://your-site.com/thanks",
|
|
102
|
+
cancelUrl: "https://your-site.com/cart",
|
|
103
|
+
});
|
|
104
|
+
if ("url" in result) redirect(result.url); // Stripe Checkout
|
|
105
|
+
else showIssues(result.issues); // sold_out | invalid_items | commerce_paused
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`store.checkoutAvailable` says whether buying works right now — the catalog
|
|
109
|
+
keeps serving while a store is paused, so sites can choose to hide the shop
|
|
110
|
+
or show it with purchasing disabled. `store.shippingCents` and
|
|
111
|
+
`store.allowedCountries` mirror what checkout will charge and accept. Ask
|
|
112
|
+
your Fullgrain Software contact for the `COMMERCE_API_KEY`.
|
|
113
|
+
|
|
114
|
+
Cart pages validate against the live catalog (no stock is held):
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
const { lines, issues, subtotalCents } = await commerce.previewCart(cartItems);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Success pages look the order up by Stripe's `{CHECKOUT_SESSION_ID}`
|
|
121
|
+
template value (add it to your `successUrl` as a query param):
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
const { order } = await commerce.getOrderStatus(searchParams.session_id);
|
|
125
|
+
// order: { status, fulfilled, trackingCarrier, items, totalCents, … } | null
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Inquiries (contact forms)
|
|
129
|
+
|
|
130
|
+
For sites with a contact/booking form. **Server-only** — the API key is a
|
|
131
|
+
secret; `createInquiries` throws if constructed in the browser.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { createInquiries } from "@fullgrainsoftware/portal";
|
|
135
|
+
|
|
136
|
+
const inquiries = createInquiries({ apiKey: process.env.INQUIRY_API_KEY! });
|
|
137
|
+
|
|
138
|
+
const result = await inquiries.submit({
|
|
139
|
+
form: "contact", // lower-kebab, ≤ 40 chars; default "contact"
|
|
140
|
+
name, email, phone, message, // name + message required; email or phone required for "contact"
|
|
141
|
+
fields: { company, subject }, // optional extras, shown as label/value
|
|
142
|
+
rating: 5, // review forms only: a whole number 1–5
|
|
143
|
+
page: "/contact", // optional, where it was sent from
|
|
144
|
+
honeypot: String(formData.get("website") ?? ""), // optional; non-empty = bot
|
|
145
|
+
meta: { ip, userAgent }, // optional; from the request headers
|
|
146
|
+
});
|
|
147
|
+
// { ok: true, id }
|
|
148
|
+
// { ok: false, error: "invalid", issues: Record<string, string> }
|
|
149
|
+
// { ok: false, error: "disabled" | "rate_limited" | "unavailable" }
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The site owns its success and error states — `submit` never throws for an
|
|
153
|
+
expected outcome. On `invalid`, show `result.issues` next to the fields; on
|
|
154
|
+
`rate_limited`, say "try again in a few minutes"; on `disabled` (the client
|
|
155
|
+
turned inquiries off in the panel) or `unavailable` (panel unreachable, a
|
|
156
|
+
bad key, or any other unexpected response — already logged via
|
|
157
|
+
`console.error`), fall back to a mailto/phone link rather than a dead form.
|
|
158
|
+
Ask your Fullgrain Software contact for the `INQUIRY_API_KEY` (`abik_…`).
|
|
159
|
+
|
|
160
|
+
## Bookings (appointments)
|
|
161
|
+
|
|
162
|
+
For sites whose business takes appointments. **Server-only** — the API key
|
|
163
|
+
is a secret; `createBookings` throws if constructed in the browser. Every
|
|
164
|
+
method returns a typed result and never throws for an expected outcome.
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
import { createBookings } from "@fullgrainsoftware/portal";
|
|
168
|
+
|
|
169
|
+
const bookings = createBookings({ apiKey: process.env.BOOKING_API_KEY! });
|
|
170
|
+
|
|
171
|
+
const services = await bookings.listServices();
|
|
172
|
+
if (!services.ok) return fallback(); // "disabled" | "unavailable" → show the phone number
|
|
173
|
+
const { timezone, requirePhone } = services; // services.services: BookingService[]; requirePhone → mark the phone field required
|
|
174
|
+
|
|
175
|
+
const days = await bookings.getAvailability({ serviceId, from: "2026-06-11", to: "2026-06-17" });
|
|
176
|
+
if (!days.ok) return days.error === "invalid" ? badRequest(days.issues) : fallback();
|
|
177
|
+
// days.days: [{ date: "2026-06-11", slots: ["2026-06-11T16:00:00.000Z", …] }]
|
|
178
|
+
|
|
179
|
+
const result = await bookings.book({
|
|
180
|
+
serviceId, start: slot, // one of the returned slots, verbatim
|
|
181
|
+
customer: { name, email, phone }, // email required; phone if the site requires it
|
|
182
|
+
note, fields: { "Intake · Area": "left arm" }, page: "/book",
|
|
183
|
+
honeypot: String(formData.get("website") ?? ""), meta: { ip, userAgent },
|
|
184
|
+
});
|
|
185
|
+
if (!result.ok) { /* "invalid" (result.issues) | "slot_taken" | "too_many_active" | "rate_limited" | "unknown_service" | "disabled" | "unavailable" */ }
|
|
186
|
+
else { /* result.booking: BookingView — its manageUrl is what the customer's emails link to */ }
|
|
187
|
+
await bookings.requestTime({ serviceId, text: "any weekday after 3", customer }); // when no slot fits
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Render slots in the business's zone with no library:
|
|
191
|
+
`new Intl.DateTimeFormat("en-US", { timeZone: timezone, weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(new Date(slot))`.
|
|
192
|
+
|
|
193
|
+
On `slot_taken`, re-fetch the day and ask for another time; on `disabled` or
|
|
194
|
+
`unavailable`, show the phone number rather than a dead picker. Customers
|
|
195
|
+
manage a booking from the `manageUrl` in their emails (the panel hosts that
|
|
196
|
+
page); a site that wants its own can set a manage URL in the panel and call
|
|
197
|
+
`getBooking(token)`, `cancel`, `reschedule`, and `respond` itself. Such a page
|
|
198
|
+
should pass back the `version` it rendered (`cancel(token, { version: booking.version })`)
|
|
199
|
+
so a stale page can't act on a booking that changed since it loaded.
|
|
200
|
+
|
|
201
|
+
## Reviews
|
|
202
|
+
|
|
203
|
+
For sites with a review form. Submit reviews through `createInquiries` with
|
|
204
|
+
`form: "review"` and a `rating` (1–5). When the client turns on review
|
|
205
|
+
moderation in the panel, each review waits in their inbox until they approve
|
|
206
|
+
it; approved reviews then arrive with your content as `website.reviews`
|
|
207
|
+
(newest first, at most 50) and refresh on the same revalidate ping as any
|
|
208
|
+
content edit.
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import { readReviews, type CMSReview } from "@fullgrainsoftware/portal";
|
|
212
|
+
|
|
213
|
+
// lib/cms.ts — through the same cached fetch as the content
|
|
214
|
+
export async function getReviews(): Promise<CMSReview[]> {
|
|
215
|
+
const websiteId = process.env.WEBSITE_ID;
|
|
216
|
+
if (!websiteId) return [];
|
|
217
|
+
try {
|
|
218
|
+
return readReviews((await getPublicSiteData(websiteId)).website);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
console.error("[cms] reviews fetch failed:", err);
|
|
221
|
+
return [];
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Each review is `{ id, name, rating, text, createdAt }`: `name` is the display
|
|
227
|
+
name the business chose (never the reviewer's email or full name), `rating`
|
|
228
|
+
may be `null`, `createdAt` is ISO 8601 UTC. `readReviews` never throws: seed
|
|
229
|
+
mode, reviews switched off, or an older panel all give `[]` — render nothing
|
|
230
|
+
(or a code-owned fallback) for an empty list, and never invent reviews.
|
|
231
|
+
Don't emit `aggregateRating` structured data from these: self-served reviews
|
|
232
|
+
aren't eligible for review stars in search.
|
|
233
|
+
|
|
234
|
+
## Live preview
|
|
235
|
+
|
|
236
|
+
`initCmsPreview()` wires a site to the panel's live preview iframe (section
|
|
237
|
+
scrolling, click-to-edit). Call it once client-side; it no-ops outside the
|
|
238
|
+
preview.
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
MIT © Alexander Jessen
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
export type BookingStatus = "requested" | "pending" | "proposed" | "confirmed" | "completed" | "no_show" | "cancelled" | "expired";
|
|
2
|
+
export interface BookingService {
|
|
3
|
+
id: string;
|
|
4
|
+
slug: string;
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
durationMin: number;
|
|
8
|
+
priceLabel: string;
|
|
9
|
+
}
|
|
10
|
+
export interface AvailabilityDay {
|
|
11
|
+
date: string;
|
|
12
|
+
slots: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface BookingView {
|
|
15
|
+
id: string;
|
|
16
|
+
status: BookingStatus;
|
|
17
|
+
service: {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
durationMin: number;
|
|
21
|
+
priceLabel: string;
|
|
22
|
+
};
|
|
23
|
+
timezone: string;
|
|
24
|
+
start: string | null;
|
|
25
|
+
end: string | null;
|
|
26
|
+
previousStart: string | null;
|
|
27
|
+
customer: {
|
|
28
|
+
name: string;
|
|
29
|
+
email: string;
|
|
30
|
+
phone: string | null;
|
|
31
|
+
};
|
|
32
|
+
note: string;
|
|
33
|
+
fields: Record<string, string>;
|
|
34
|
+
requestedText: string | null;
|
|
35
|
+
manageUrl: string;
|
|
36
|
+
can: {
|
|
37
|
+
cancel: boolean;
|
|
38
|
+
reschedule: boolean;
|
|
39
|
+
respond: boolean;
|
|
40
|
+
};
|
|
41
|
+
policy: {
|
|
42
|
+
cancelCutoffMin: number;
|
|
43
|
+
rescheduleCutoffMin: number;
|
|
44
|
+
reschedulesLeft: number;
|
|
45
|
+
contactPhone: string;
|
|
46
|
+
};
|
|
47
|
+
business: {
|
|
48
|
+
name: string;
|
|
49
|
+
location: string;
|
|
50
|
+
};
|
|
51
|
+
/** Bumps on every time change. Pass it back to cancel / reschedule / respond
|
|
52
|
+
* so a page rendered before a change can't act on the changed booking. */
|
|
53
|
+
version: number;
|
|
54
|
+
}
|
|
55
|
+
export interface BookingCustomer {
|
|
56
|
+
name: string;
|
|
57
|
+
email: string;
|
|
58
|
+
phone?: string | null;
|
|
59
|
+
}
|
|
60
|
+
export interface BookInput {
|
|
61
|
+
serviceId: string;
|
|
62
|
+
/** ISO 8601 instant, exactly one of the slots from getAvailability. */
|
|
63
|
+
start: string;
|
|
64
|
+
customer: BookingCustomer;
|
|
65
|
+
note?: string | null;
|
|
66
|
+
fields?: Record<string, string>;
|
|
67
|
+
page?: string | null;
|
|
68
|
+
honeypot?: string | null;
|
|
69
|
+
meta?: {
|
|
70
|
+
ip?: string | null;
|
|
71
|
+
userAgent?: string | null;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export interface RequestTimeInput {
|
|
75
|
+
serviceId: string;
|
|
76
|
+
text: string;
|
|
77
|
+
customer: BookingCustomer;
|
|
78
|
+
fields?: Record<string, string>;
|
|
79
|
+
page?: string | null;
|
|
80
|
+
honeypot?: string | null;
|
|
81
|
+
meta?: {
|
|
82
|
+
ip?: string | null;
|
|
83
|
+
userAgent?: string | null;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
type Unavailable = {
|
|
87
|
+
ok: false;
|
|
88
|
+
error: "unavailable";
|
|
89
|
+
};
|
|
90
|
+
type Invalid = {
|
|
91
|
+
ok: false;
|
|
92
|
+
error: "invalid";
|
|
93
|
+
issues: Record<string, string>;
|
|
94
|
+
};
|
|
95
|
+
export type ServicesResult = {
|
|
96
|
+
ok: true;
|
|
97
|
+
timezone: string;
|
|
98
|
+
/** The site's "require phone" setting: a booking or request without a phone is `invalid`. */
|
|
99
|
+
requirePhone: boolean;
|
|
100
|
+
services: BookingService[];
|
|
101
|
+
} | {
|
|
102
|
+
ok: false;
|
|
103
|
+
error: "disabled";
|
|
104
|
+
} | Unavailable;
|
|
105
|
+
export type AvailabilityResult = {
|
|
106
|
+
ok: true;
|
|
107
|
+
timezone: string;
|
|
108
|
+
service: BookingService;
|
|
109
|
+
days: AvailabilityDay[];
|
|
110
|
+
} | Invalid | {
|
|
111
|
+
ok: false;
|
|
112
|
+
error: "unknown_service" | "disabled";
|
|
113
|
+
} | Unavailable;
|
|
114
|
+
export type BookResult = {
|
|
115
|
+
ok: true;
|
|
116
|
+
booking: BookingView;
|
|
117
|
+
} | Invalid | {
|
|
118
|
+
ok: false;
|
|
119
|
+
error: "slot_taken" | "too_many_active" | "rate_limited" | "unknown_service" | "disabled";
|
|
120
|
+
} | Unavailable;
|
|
121
|
+
export type RequestTimeResult = {
|
|
122
|
+
ok: true;
|
|
123
|
+
booking: BookingView;
|
|
124
|
+
} | Invalid | {
|
|
125
|
+
ok: false;
|
|
126
|
+
error: "rate_limited" | "unknown_service" | "disabled";
|
|
127
|
+
} | Unavailable;
|
|
128
|
+
export type GetBookingResult = {
|
|
129
|
+
ok: true;
|
|
130
|
+
booking: BookingView;
|
|
131
|
+
} | {
|
|
132
|
+
ok: false;
|
|
133
|
+
error: "not_found" | "disabled";
|
|
134
|
+
} | Unavailable;
|
|
135
|
+
export type ManageResult = {
|
|
136
|
+
ok: true;
|
|
137
|
+
booking: BookingView;
|
|
138
|
+
} | Invalid | {
|
|
139
|
+
ok: false;
|
|
140
|
+
error: "cutoff" | "limit_reached" | "slot_taken" | "invalid_transition" | "not_found" | "disabled";
|
|
141
|
+
} | Unavailable;
|
|
142
|
+
export interface Bookings {
|
|
143
|
+
listServices(): Promise<ServicesResult>;
|
|
144
|
+
getAvailability(args: {
|
|
145
|
+
serviceId: string;
|
|
146
|
+
from: string;
|
|
147
|
+
to?: string;
|
|
148
|
+
}): Promise<AvailabilityResult>;
|
|
149
|
+
book(input: BookInput): Promise<BookResult>;
|
|
150
|
+
requestTime(input: RequestTimeInput): Promise<RequestTimeResult>;
|
|
151
|
+
getBooking(token: string): Promise<GetBookingResult>;
|
|
152
|
+
cancel(token: string, opts?: {
|
|
153
|
+
reason?: string | null;
|
|
154
|
+
version?: number;
|
|
155
|
+
}): Promise<ManageResult>;
|
|
156
|
+
reschedule(token: string, opts: {
|
|
157
|
+
start: string;
|
|
158
|
+
version?: number;
|
|
159
|
+
}): Promise<ManageResult>;
|
|
160
|
+
respond(token: string, opts: {
|
|
161
|
+
accept: boolean;
|
|
162
|
+
version?: number;
|
|
163
|
+
}): Promise<ManageResult>;
|
|
164
|
+
}
|
|
165
|
+
export declare function createBookings(opts: {
|
|
166
|
+
apiKey: string;
|
|
167
|
+
baseUrl?: string;
|
|
168
|
+
}): Bookings;
|
|
169
|
+
export {};
|
package/dist/bookings.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* Bookings (spec 2026-09-28): a site lists services, fetches open slots,
|
|
2
|
+
books or requests a time, and lets the customer act on a booking through
|
|
3
|
+
its manage token. Server-only — the abbk_ key is a secret — and every
|
|
4
|
+
method returns a typed result instead of throwing for an expected
|
|
5
|
+
outcome, so a site can never show a dead picker. */
|
|
6
|
+
const DEFAULT_BASE_URL = "https://www.abdevpanel.com";
|
|
7
|
+
const TIMEOUT_MS = 10_000;
|
|
8
|
+
const versionOf = (o) => (typeof o?.version === "number" ? { version: o.version } : {});
|
|
9
|
+
export function createBookings(opts) {
|
|
10
|
+
if (typeof window !== "undefined") {
|
|
11
|
+
throw new Error("createBookings is server-only — never expose BOOKING_API_KEY to the browser");
|
|
12
|
+
}
|
|
13
|
+
if (!opts.apiKey?.startsWith("abbk_")) {
|
|
14
|
+
throw new Error("createBookings needs a booking API key (abbk_…)");
|
|
15
|
+
}
|
|
16
|
+
const base = (opts.baseUrl ?? process.env.AB_CMS_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
17
|
+
const unavailable = (why) => {
|
|
18
|
+
// The manage token is a bearer credential for the booking: never log it.
|
|
19
|
+
console.error(`[fullgrain bookings] ${why.replace(/\/manage\/[^\s?#)]+/g, "/manage/…")}`);
|
|
20
|
+
return { ok: false, error: "unavailable" };
|
|
21
|
+
};
|
|
22
|
+
async function call(path, init) {
|
|
23
|
+
let res;
|
|
24
|
+
try {
|
|
25
|
+
res = await fetch(`${base}${path}`, {
|
|
26
|
+
method: init.method,
|
|
27
|
+
headers: { Authorization: `Bearer ${opts.apiKey}`, ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}) },
|
|
28
|
+
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
|
|
29
|
+
cache: "no-store",
|
|
30
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
return { failed: `request failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
35
|
+
}
|
|
36
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
37
|
+
if (!contentType.includes("application/json"))
|
|
38
|
+
return { failed: `non-JSON response ${res.status} from ${base}${path}` };
|
|
39
|
+
const body = (await res.json().catch(() => null));
|
|
40
|
+
return { status: res.status, body };
|
|
41
|
+
}
|
|
42
|
+
// One mapper for every method: a 200 with ok:true is the body; the listed
|
|
43
|
+
// error codes pass through by status; anything else (401, 5xx, an error
|
|
44
|
+
// the method never declares, junk) is unavailable — so runtime values
|
|
45
|
+
// never escape a method's declared union.
|
|
46
|
+
function map(f, path, allowed) {
|
|
47
|
+
if ("failed" in f)
|
|
48
|
+
return unavailable(f.failed);
|
|
49
|
+
const { status, body } = f;
|
|
50
|
+
if (status === 200 && body?.ok === true)
|
|
51
|
+
return body;
|
|
52
|
+
if (status === 400 && body?.error === "invalid" && allowed.includes("invalid"))
|
|
53
|
+
return { ok: false, error: "invalid", issues: body.issues ?? {} };
|
|
54
|
+
if (status === 403 && allowed.includes("disabled"))
|
|
55
|
+
return { ok: false, error: "disabled" };
|
|
56
|
+
if (status === 429 && allowed.includes("rate_limited"))
|
|
57
|
+
return { ok: false, error: "rate_limited" };
|
|
58
|
+
if ((status === 404 || status === 409) && typeof body?.error === "string" && allowed.includes(body.error))
|
|
59
|
+
return { ok: false, error: body.error };
|
|
60
|
+
return unavailable(`unexpected response ${status}${body?.error ? ` (${body.error})` : ""} from ${base}${path}`);
|
|
61
|
+
}
|
|
62
|
+
const manage = (token, body) => {
|
|
63
|
+
const path = `/api/booking/manage/${encodeURIComponent(token)}`;
|
|
64
|
+
return call(path, { method: "POST", body }).then((f) => map(f, path, ["invalid", "not_found", "cutoff", "limit_reached", "slot_taken", "invalid_transition", "disabled"]));
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
async listServices() {
|
|
68
|
+
const path = "/api/booking/services";
|
|
69
|
+
const result = map(await call(path, { method: "GET" }), path, ["disabled"]);
|
|
70
|
+
// Always a boolean, even from a panel that predates the field.
|
|
71
|
+
return result.ok ? { ...result, requirePhone: result.requirePhone === true } : result;
|
|
72
|
+
},
|
|
73
|
+
async getAvailability({ serviceId, from, to }) {
|
|
74
|
+
const qs = new URLSearchParams({ service: serviceId, from });
|
|
75
|
+
if (to)
|
|
76
|
+
qs.set("to", to);
|
|
77
|
+
const path = `/api/booking/availability?${qs.toString()}`;
|
|
78
|
+
return map(await call(path, { method: "GET" }), path, ["invalid", "unknown_service", "disabled"]);
|
|
79
|
+
},
|
|
80
|
+
async book(input) {
|
|
81
|
+
const path = "/api/booking/book";
|
|
82
|
+
return map(await call(path, { method: "POST", body: input }), path, ["invalid", "unknown_service", "slot_taken", "too_many_active", "rate_limited", "disabled"]);
|
|
83
|
+
},
|
|
84
|
+
async requestTime(input) {
|
|
85
|
+
const path = "/api/booking/request";
|
|
86
|
+
return map(await call(path, { method: "POST", body: input }), path, ["invalid", "unknown_service", "rate_limited", "disabled"]);
|
|
87
|
+
},
|
|
88
|
+
async getBooking(token) {
|
|
89
|
+
const path = `/api/booking/manage/${encodeURIComponent(token)}`;
|
|
90
|
+
return map(await call(path, { method: "GET" }), path, ["not_found", "disabled"]);
|
|
91
|
+
},
|
|
92
|
+
cancel: (token, o) => manage(token, { action: "cancel", ...(o?.reason ? { reason: o.reason } : {}), ...versionOf(o) }),
|
|
93
|
+
reschedule: (token, o) => manage(token, { action: "reschedule", start: o.start, ...versionOf(o) }),
|
|
94
|
+
respond: (token, o) => manage(token, { action: o.accept ? "accept" : "decline", ...versionOf(o) }),
|
|
95
|
+
};
|
|
96
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type SyncReport = {
|
|
2
|
+
dryRun: boolean;
|
|
3
|
+
website: {
|
|
4
|
+
id: string;
|
|
5
|
+
alias: string;
|
|
6
|
+
name: string;
|
|
7
|
+
};
|
|
8
|
+
created: string[];
|
|
9
|
+
updated: string[];
|
|
10
|
+
seeded: string[];
|
|
11
|
+
skipped: string[];
|
|
12
|
+
orphans: string[];
|
|
13
|
+
};
|
|
14
|
+
export declare function formatReport(report: SyncReport): string;
|
|
15
|
+
export declare function runSync(argv: string[]): Promise<number>;
|
|
16
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createJiti } from "jiti";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { siteManifestSchema } from "./manifest.js";
|
|
4
|
+
export function formatReport(report) {
|
|
5
|
+
const lines = [];
|
|
6
|
+
lines.push(`${report.dryRun ? "DRY RUN — no changes applied" : "Applied"} — website "${report.website.name}" (alias ${report.website.alias})`);
|
|
7
|
+
for (const c of report.created)
|
|
8
|
+
lines.push(` + ${c}`);
|
|
9
|
+
for (const u of report.updated)
|
|
10
|
+
lines.push(` * updated: ${u}`);
|
|
11
|
+
for (const s of report.seeded)
|
|
12
|
+
lines.push(` ~ seeded: ${s}`);
|
|
13
|
+
for (const s of report.skipped)
|
|
14
|
+
lines.push(` - skipped: ${s}`);
|
|
15
|
+
for (const o of report.orphans)
|
|
16
|
+
lines.push(` ! orphan: ${o}`);
|
|
17
|
+
if (!report.created.length && !report.updated.length &&
|
|
18
|
+
!report.seeded.length && !report.skipped.length && !report.orphans.length) {
|
|
19
|
+
lines.push(" No changes.");
|
|
20
|
+
}
|
|
21
|
+
return lines.join("\n");
|
|
22
|
+
}
|
|
23
|
+
export async function runSync(argv) {
|
|
24
|
+
if (argv[0] !== "sync") {
|
|
25
|
+
console.error("Usage: fullgrain sync [--dry-run] [--manifest <path>]");
|
|
26
|
+
return 2;
|
|
27
|
+
}
|
|
28
|
+
// Validate flags and FAIL CLOSED on anything unrecognised. `sync` performs an
|
|
29
|
+
// irreversible, content-destroying apply when --dry-run is absent, so a typo
|
|
30
|
+
// like `--dryrun` or `-n` must be an error, never a silent full apply.
|
|
31
|
+
const manifestFlag = argv.indexOf("--manifest");
|
|
32
|
+
const flags = argv.slice(1);
|
|
33
|
+
for (let i = 0; i < flags.length; i++) {
|
|
34
|
+
const arg = flags[i];
|
|
35
|
+
if (arg === "--manifest") {
|
|
36
|
+
i++; // its value is consumed, not a flag
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (arg === "--dry-run")
|
|
40
|
+
continue;
|
|
41
|
+
if (arg.startsWith("-")) {
|
|
42
|
+
console.error(`Unknown flag "${arg}"\nUsage: fullgrain sync [--dry-run] [--manifest <path>]`);
|
|
43
|
+
return 2;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const dryRun = argv.includes("--dry-run");
|
|
47
|
+
const manifestArg = manifestFlag !== -1 ? argv[manifestFlag + 1] : "cms.manifest.ts";
|
|
48
|
+
if (manifestFlag !== -1 && (!manifestArg || manifestArg.startsWith("--"))) {
|
|
49
|
+
console.error("--manifest requires a path");
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
const manifestPath = path.resolve(manifestArg);
|
|
53
|
+
const apiKey = process.env.AB_CMS_API_KEY;
|
|
54
|
+
if (!apiKey) {
|
|
55
|
+
console.error("AB_CMS_API_KEY is not set");
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
const baseUrl = (process.env.AB_CMS_URL ?? "https://www.abdevpanel.com").replace(/\/$/, "");
|
|
59
|
+
const jiti = createJiti(import.meta.url);
|
|
60
|
+
let loaded;
|
|
61
|
+
try {
|
|
62
|
+
loaded = await jiti.import(manifestPath, { default: true });
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
66
|
+
console.error(`Could not load manifest at ${manifestPath}: ${message}`);
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
const parsed = siteManifestSchema.safeParse(loaded);
|
|
70
|
+
if (!parsed.success) {
|
|
71
|
+
console.error("Manifest is invalid:");
|
|
72
|
+
for (const issue of parsed.error.issues) {
|
|
73
|
+
console.error(` - ${issue.path.join(".")}: ${issue.message}`);
|
|
74
|
+
}
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
let report;
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch(`${baseUrl}/api/admin/sync-site`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: {
|
|
82
|
+
"Content-Type": "application/json",
|
|
83
|
+
Authorization: `Bearer ${apiKey}`,
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({ manifest: parsed.data, dryRun }),
|
|
86
|
+
});
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const text = await response.text();
|
|
89
|
+
let parsedBody = null;
|
|
90
|
+
try {
|
|
91
|
+
parsedBody = JSON.parse(text);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
parsedBody = null;
|
|
95
|
+
}
|
|
96
|
+
if (parsedBody && typeof parsedBody === "object" && "partialReport" in parsedBody) {
|
|
97
|
+
const body = parsedBody;
|
|
98
|
+
console.error(`Sync failed (${response.status}): ${body.message ?? ""}`);
|
|
99
|
+
console.error("Partial result:");
|
|
100
|
+
console.error(formatReport(body.partialReport));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
console.error(`Sync failed (${response.status}): ${text}`);
|
|
104
|
+
}
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
report = (await response.json());
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
111
|
+
console.error(`Could not reach ${baseUrl}: ${message}`);
|
|
112
|
+
return 1;
|
|
113
|
+
}
|
|
114
|
+
console.log(formatReport(report));
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PublicSiteData } from "./cms-types.js";
|
|
2
|
+
export declare function fetchPublicSiteData(websiteId: string, opts?: {
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
/** Abort the request after this many ms (default 10000). A hung panel must
|
|
5
|
+
* not hang the awaiting Server Component render. */
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
/** Caller-supplied abort signal; combined with the internal timeout. */
|
|
8
|
+
signal?: AbortSignal;
|
|
9
|
+
}): Promise<PublicSiteData>;
|