@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.
@@ -0,0 +1,38 @@
1
+ const DEFAULT_BASE_URL = "https://www.abdevpanel.com";
2
+ export async function fetchPublicSiteData(websiteId, opts) {
3
+ const base = (opts?.baseUrl ?? process.env.AB_CMS_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
4
+ const timeoutMs = opts?.timeoutMs ?? 10_000;
5
+ const controller = new AbortController();
6
+ const onAbort = () => controller.abort();
7
+ if (opts?.signal) {
8
+ if (opts.signal.aborted)
9
+ controller.abort();
10
+ else
11
+ opts.signal.addEventListener("abort", onAbort, { once: true });
12
+ }
13
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
14
+ try {
15
+ const response = await fetch(`${base}/api/public-site-data`, {
16
+ method: "POST",
17
+ headers: { "Content-Type": "application/json" },
18
+ body: JSON.stringify({ website_id: websiteId }),
19
+ cache: "no-store",
20
+ signal: controller.signal,
21
+ });
22
+ if (!response.ok) {
23
+ const errorText = await response.text();
24
+ throw new Error(`Failed to fetch CMS data: ${response.status} - ${errorText}`);
25
+ }
26
+ return (await response.json());
27
+ }
28
+ catch (err) {
29
+ if (controller.signal.aborted && !opts?.signal?.aborted) {
30
+ throw new Error(`CMS request timed out after ${timeoutMs}ms (${base})`);
31
+ }
32
+ throw err;
33
+ }
34
+ finally {
35
+ clearTimeout(timer);
36
+ opts?.signal?.removeEventListener("abort", onAbort);
37
+ }
38
+ }
@@ -0,0 +1,99 @@
1
+ export type CMSStaticRendition = {
2
+ name: string | null;
3
+ url: string | null;
4
+ width: number | null;
5
+ height: number | null;
6
+ bitrate: number | null;
7
+ };
8
+ export type CMSVideo = {
9
+ id: string;
10
+ mux_asset_id: string;
11
+ mux_playback_id: string;
12
+ playback_policy: string;
13
+ status: string;
14
+ duration_sec: number | null;
15
+ width: number | null;
16
+ height: number | null;
17
+ aspect_ratio: string | null;
18
+ mp4_support: boolean | null;
19
+ passthrough: string | null;
20
+ created_at: string | null;
21
+ /** Best static MP4 (≤1080p) for direct <video src>. */
22
+ playback_url: string | null;
23
+ /** HLS manifest for adaptive playback (`.m3u8`). */
24
+ hls_url: string | null;
25
+ /** ~720p MP4, handy for a muted autoplay background. Absent on legacy
26
+ * value-only fallbacks. */
27
+ background_url?: string | null;
28
+ /** All named MP4 renditions the asset produced (absent on fallbacks). */
29
+ static_renditions?: CMSStaticRendition[];
30
+ };
31
+ export type CMSComponent = {
32
+ id: string;
33
+ page_id?: string;
34
+ section_id?: string;
35
+ section_group_id?: string;
36
+ type: string;
37
+ key: string;
38
+ value: string | null;
39
+ sort_order: number;
40
+ can_modify: boolean;
41
+ can_modify_type: boolean;
42
+ category: string;
43
+ video: CMSVideo | null;
44
+ };
45
+ export type CMSSectionGroup = {
46
+ id: string;
47
+ section_id: string;
48
+ name: string;
49
+ sort_order: number;
50
+ /** Sort tiebreaker within a section (matches the panel's own ordering). */
51
+ created_at?: string;
52
+ components: CMSComponent[];
53
+ subgroups?: CMSSectionGroup[];
54
+ parent_group_id?: string | null;
55
+ };
56
+ export type CMSSection = {
57
+ id: string;
58
+ page_id: string;
59
+ title: string;
60
+ can_modify: boolean;
61
+ sort_order: number;
62
+ allow_group_creation: boolean;
63
+ group_schema: unknown[] | null;
64
+ subgroup_schema?: unknown[] | null;
65
+ components: CMSComponent[];
66
+ groups: CMSSectionGroup[];
67
+ };
68
+ export type CMSPage = {
69
+ id: string;
70
+ website_id: string;
71
+ slug: string;
72
+ title: string;
73
+ can_modify: boolean;
74
+ sections: CMSSection[];
75
+ };
76
+ /** One approved review (panel reviews spec 2026-09-29 §6.1). `name` is the
77
+ * display name the business chose; `rating` is 1–5 or null. */
78
+ export type CMSReview = {
79
+ id: string;
80
+ name: string;
81
+ rating: number | null;
82
+ text: string;
83
+ /** ISO 8601, UTC. */
84
+ createdAt: string;
85
+ };
86
+ export type CMSWebsite = {
87
+ id: string;
88
+ user_id: string;
89
+ name: string;
90
+ domain: string;
91
+ alias: string;
92
+ created_at: string;
93
+ pages: CMSPage[];
94
+ /** Present only when the site has reviews switched on in the panel; read it with readReviews. */
95
+ reviews?: CMSReview[];
96
+ };
97
+ export type PublicSiteData = {
98
+ website: CMSWebsite;
99
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,111 @@
1
+ export type StoreStatus = {
2
+ checkoutAvailable: boolean;
3
+ currency: string;
4
+ /** Flat per-order shipping the store charges at checkout (0 = free). */
5
+ shippingCents: number;
6
+ /** 2-letter codes checkout will accept shipping addresses from. */
7
+ allowedCountries: string[];
8
+ };
9
+ export type LineIssue = {
10
+ variantId: string;
11
+ reason: "sold_out" | "inactive" | "not_found" | "quantity";
12
+ /** For sold_out: how many are actually available right now. */
13
+ available?: number;
14
+ };
15
+ export type ProductSummary = {
16
+ id: string;
17
+ slug: string;
18
+ name: string;
19
+ category: string | null;
20
+ priceRangeCents: [number, number];
21
+ images: string[];
22
+ soldOut: boolean;
23
+ };
24
+ export type VariantView = {
25
+ id: string;
26
+ valueIds: string[];
27
+ sku: string | null;
28
+ priceCents: number;
29
+ salePriceCents: number | null;
30
+ available: boolean;
31
+ };
32
+ export type ProductDetail = ProductSummary & {
33
+ description: string | null;
34
+ options: {
35
+ id: string;
36
+ name: string;
37
+ values: {
38
+ id: string;
39
+ value: string;
40
+ }[];
41
+ }[];
42
+ variants: VariantView[];
43
+ };
44
+ export type CheckoutInput = {
45
+ items: {
46
+ variantId: string;
47
+ quantity: number;
48
+ }[];
49
+ /** Absolute URL on the site's own domain. */
50
+ successUrl: string;
51
+ cancelUrl: string;
52
+ email?: string;
53
+ };
54
+ export type CheckoutResult = {
55
+ url: string;
56
+ } | {
57
+ error: "commerce_paused" | "sold_out" | "invalid_items" | "stripe_error";
58
+ issues?: LineIssue[];
59
+ };
60
+ export type CartPreview = {
61
+ store: StoreStatus;
62
+ lines: {
63
+ variantId: string;
64
+ label: string;
65
+ unitAmountCents: number;
66
+ quantity: number;
67
+ }[];
68
+ issues: LineIssue[];
69
+ subtotalCents: number;
70
+ };
71
+ export type OrderStatus = {
72
+ status: "pending" | "paid" | "canceled" | "refunded";
73
+ fulfilled: boolean;
74
+ trackingCarrier: string | null;
75
+ trackingNumber: string | null;
76
+ items: {
77
+ name: string;
78
+ quantity: number;
79
+ priceCents: number;
80
+ }[];
81
+ totalCents: number;
82
+ refundedCents: number;
83
+ shippingName: string | null;
84
+ };
85
+ export interface Commerce {
86
+ listProducts(): Promise<{
87
+ store: StoreStatus;
88
+ products: ProductSummary[];
89
+ }>;
90
+ getProduct(slug: string): Promise<{
91
+ store: StoreStatus;
92
+ product: ProductDetail | null;
93
+ }>;
94
+ createCheckout(input: CheckoutInput): Promise<CheckoutResult>;
95
+ /** Authoritative prices + availability for a cart page. Reports issues,
96
+ never blocks, takes no inventory claims. */
97
+ previewCart(items: {
98
+ variantId: string;
99
+ quantity: number;
100
+ }[]): Promise<CartPreview>;
101
+ /** Success/order-page lookup by the Stripe {CHECKOUT_SESSION_ID}
102
+ template value. Unknown session → { order: null }. */
103
+ getOrderStatus(sessionId: string): Promise<{
104
+ store: StoreStatus;
105
+ order: OrderStatus | null;
106
+ }>;
107
+ }
108
+ export declare function createCommerce(opts: {
109
+ apiKey: string;
110
+ baseUrl?: string;
111
+ }): Commerce;
@@ -0,0 +1,91 @@
1
+ /* Storefront commerce client (Lite Commerce). SERVER-ONLY: the API key is a
2
+ secret — createCommerce throws if constructed in a browser so a site can
3
+ never accidentally ship COMMERCE_API_KEY to the client.
4
+
5
+ Contract (frozen at 0.4.0, additive changes only): every response carries
6
+ a `store` block; catalog reads keep serving while a store is paused
7
+ (checkoutAvailable tells the site whether to offer buying); checkout
8
+ returns a typed error union instead of throwing for expected outcomes. */
9
+ const DEFAULT_BASE_URL = "https://www.abdevpanel.com";
10
+ export function createCommerce(opts) {
11
+ if (typeof window !== "undefined") {
12
+ throw new Error("createCommerce is server-only — never expose COMMERCE_API_KEY to the browser");
13
+ }
14
+ if (!opts.apiKey?.startsWith("absk_")) {
15
+ throw new Error("createCommerce needs a storefront API key (absk_…)");
16
+ }
17
+ const base = (opts.baseUrl ?? process.env.AB_CMS_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
18
+ const request = async (path, init) => {
19
+ return fetch(`${base}${path}`, {
20
+ ...init,
21
+ headers: {
22
+ Authorization: `Bearer ${opts.apiKey}`,
23
+ "Content-Type": "application/json",
24
+ ...init?.headers,
25
+ },
26
+ cache: "no-store",
27
+ });
28
+ };
29
+ const failFrom = async (response, path) => {
30
+ const text = await response.text();
31
+ throw new Error(`Commerce request failed: ${response.status} ${path} - ${text}`);
32
+ };
33
+ // Parse a response we've decided to accept, but only if it's actually JSON.
34
+ // A wrong AB_CMS_URL, a Vercel deployment-protection page, or a CDN block can
35
+ // return a 200/404 whose body is HTML — res.json() would throw an opaque
36
+ // SyntaxError; surface the same informative failure as every other path.
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
+ const jsonOrFail = async (response, path) => {
39
+ const contentType = response.headers.get("content-type") ?? "";
40
+ if (!contentType.includes("application/json")) {
41
+ const text = await response.text();
42
+ throw new Error(`Commerce request returned non-JSON: ${response.status} ${path} - ${text.slice(0, 200)}`);
43
+ }
44
+ return response.json();
45
+ };
46
+ return {
47
+ async listProducts() {
48
+ const res = await request("/api/storefront/products");
49
+ if (!res.ok)
50
+ return failFrom(res, "/api/storefront/products");
51
+ return jsonOrFail(res, "/api/storefront/products");
52
+ },
53
+ async getProduct(slug) {
54
+ const path = `/api/storefront/products/${encodeURIComponent(slug)}`;
55
+ const res = await request(path);
56
+ // 404 keeps the store block; it's a normal "no such product" answer.
57
+ if (!res.ok && res.status !== 404)
58
+ return failFrom(res, path);
59
+ return jsonOrFail(res, path);
60
+ },
61
+ async createCheckout(input) {
62
+ const res = await request("/api/storefront/checkout", {
63
+ method: "POST",
64
+ body: JSON.stringify(input),
65
+ });
66
+ // Typed bodies are expected outcomes, not exceptions: 400/409 carry a
67
+ // LineIssue error, 502 carries { error: "stripe_error" } for an outage.
68
+ if (res.ok || res.status === 400 || res.status === 409 || res.status === 502) {
69
+ return jsonOrFail(res, "/api/storefront/checkout");
70
+ }
71
+ return failFrom(res, "/api/storefront/checkout");
72
+ },
73
+ async previewCart(items) {
74
+ const res = await request("/api/storefront/cart-preview", {
75
+ method: "POST",
76
+ body: JSON.stringify({ items }),
77
+ });
78
+ if (!res.ok)
79
+ return failFrom(res, "/api/storefront/cart-preview");
80
+ return jsonOrFail(res, "/api/storefront/cart-preview");
81
+ },
82
+ async getOrderStatus(sessionId) {
83
+ const path = `/api/storefront/order-status?session_id=${encodeURIComponent(sessionId)}`;
84
+ const res = await request(path);
85
+ // 404 = unknown session, a normal answer carrying { order: null }.
86
+ if (!res.ok && res.status !== 404)
87
+ return failFrom(res, path);
88
+ return jsonOrFail(res, path);
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,15 @@
1
+ export { defineSite, siteManifestSchema } from "./manifest.js";
2
+ export type { FieldType, ManifestField, ManifestPage, ManifestSection, ManifestSeedItem, SiteManifest, } from "./manifest.js";
3
+ export { readSiteContent, cmsItemProps } from "./reader.js";
4
+ export type { SiteContent, CmsItemMeta } from "./reader.js";
5
+ export { fetchPublicSiteData } from "./cms-client.js";
6
+ export { readReviews } from "./reviews.js";
7
+ export type { CMSComponent, CMSPage, CMSReview, CMSSection, CMSSectionGroup, CMSStaticRendition, CMSVideo, CMSWebsite, PublicSiteData, } from "./cms-types.js";
8
+ export { createCommerce } from "./commerce.js";
9
+ export type { CartPreview, CheckoutInput, CheckoutResult, Commerce, LineIssue, OrderStatus, ProductDetail, ProductSummary, StoreStatus, VariantView, } from "./commerce.js";
10
+ export { createInquiries } from "./inquiries.js";
11
+ export type { Inquiries, InquiryInput, InquirySubmitResult } from "./inquiries.js";
12
+ export { initCmsPreview, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
13
+ export type { CmsPreviewOptions, PreviewInbound, PreviewOutbound } from "./preview.js";
14
+ export { createBookings } from "./bookings.js";
15
+ export type { AvailabilityDay, AvailabilityResult, BookInput, BookResult, BookingCustomer, BookingService, BookingStatus, BookingView, Bookings, GetBookingResult, ManageResult, RequestTimeInput, RequestTimeResult, ServicesResult, } from "./bookings.js";
package/dist/index.js ADDED
@@ -0,0 +1,8 @@
1
+ export { defineSite, siteManifestSchema } from "./manifest.js";
2
+ export { readSiteContent, cmsItemProps } from "./reader.js";
3
+ export { fetchPublicSiteData } from "./cms-client.js";
4
+ export { readReviews } from "./reviews.js";
5
+ export { createCommerce } from "./commerce.js";
6
+ export { createInquiries } from "./inquiries.js";
7
+ export { initCmsPreview, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
8
+ export { createBookings } from "./bookings.js";
@@ -0,0 +1,38 @@
1
+ export interface InquiryInput {
2
+ /** lower-kebab, ≤ 40 chars; "contact" by default. */
3
+ form?: string;
4
+ name: string;
5
+ email?: string | null;
6
+ phone?: string | null;
7
+ message: string;
8
+ /** Extra site-specific fields, shown as label/value. */
9
+ fields?: Record<string, string>;
10
+ /** For `form: "review"`: a whole number 1–5. Anything else is an `invalid` result with `issues.rating`. */
11
+ rating?: number;
12
+ /** Where it was sent from, e.g. "/contact". */
13
+ page?: string | null;
14
+ /** A hidden field a human never fills; non-empty means a bot. */
15
+ honeypot?: string | null;
16
+ meta?: {
17
+ ip?: string | null;
18
+ userAgent?: string | null;
19
+ };
20
+ }
21
+ export type InquirySubmitResult = {
22
+ ok: true;
23
+ id: string;
24
+ } | {
25
+ ok: false;
26
+ error: "invalid";
27
+ issues: Record<string, string>;
28
+ } | {
29
+ ok: false;
30
+ error: "disabled" | "rate_limited" | "unavailable";
31
+ };
32
+ export interface Inquiries {
33
+ submit(input: InquiryInput): Promise<InquirySubmitResult>;
34
+ }
35
+ export declare function createInquiries(opts: {
36
+ apiKey: string;
37
+ baseUrl?: string;
38
+ }): Inquiries;
@@ -0,0 +1,52 @@
1
+ /* Inquiries (spec 2026-09-21): a site's contact form hands each submission
2
+ to the panel, which stores it, decides spam, and emails the site's
3
+ recipients. Server-only — the abik_ key is a secret — and `submit` never
4
+ throws for an expected outcome: it returns a typed result the site turns
5
+ into its own success, "try again in a few minutes", or fallback state. */
6
+ const DEFAULT_BASE_URL = "https://www.abdevpanel.com";
7
+ const TIMEOUT_MS = 10_000;
8
+ export function createInquiries(opts) {
9
+ if (typeof window !== "undefined") {
10
+ throw new Error("createInquiries is server-only — never expose INQUIRY_API_KEY to the browser");
11
+ }
12
+ if (!opts.apiKey?.startsWith("abik_")) {
13
+ throw new Error("createInquiries needs an inquiry API key (abik_…)");
14
+ }
15
+ const base = (opts.baseUrl ?? process.env.AB_CMS_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
16
+ const path = "/api/inquiries/submit";
17
+ const unavailable = (why) => {
18
+ console.error(`[fullgrain inquiries] ${why}`);
19
+ return { ok: false, error: "unavailable" };
20
+ };
21
+ return {
22
+ async submit(input) {
23
+ let res;
24
+ try {
25
+ res = await fetch(`${base}${path}`, {
26
+ method: "POST",
27
+ headers: { Authorization: `Bearer ${opts.apiKey}`, "Content-Type": "application/json" },
28
+ body: JSON.stringify(input),
29
+ cache: "no-store",
30
+ signal: AbortSignal.timeout(TIMEOUT_MS),
31
+ });
32
+ }
33
+ catch (err) {
34
+ return unavailable(`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 unavailable(`non-JSON response ${res.status} from ${base}${path}`);
39
+ }
40
+ const body = (await res.json().catch(() => null));
41
+ if (res.status === 200 && body?.ok && typeof body.id === "string")
42
+ return { ok: true, id: body.id };
43
+ if (res.status === 400 && body?.error === "invalid")
44
+ return { ok: false, error: "invalid", issues: body.issues ?? {} };
45
+ if (res.status === 403)
46
+ return { ok: false, error: "disabled" };
47
+ if (res.status === 429)
48
+ return { ok: false, error: "rate_limited" };
49
+ return unavailable(`unexpected response ${res.status}${body?.error ? ` (${body.error})` : ""} from ${base}${path}`);
50
+ },
51
+ };
52
+ }