@fullgrainsoftware/portal 0.11.0 → 0.12.1

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/README.md CHANGED
@@ -159,6 +159,99 @@ bad key, or any other unexpected response — already logged via
159
159
  `console.error`), fall back to a mailto/phone link rather than a dead form.
160
160
  Ask your Fullgrain Software contact for the `INQUIRY_API_KEY` (`abik_…`).
161
161
 
162
+ ### Attachments (photos)
163
+
164
+ A contact form can carry up to five photos. The panel admin turns **Accept
165
+ photos** on for the site first (the site's Inbox tab); until then every
166
+ upload answers `disabled`. Photos go browser → the site's own route →
167
+ `uploadPhoto`, one photo per request, and the message then names them by id.
168
+
169
+ In the browser, prepare each picked file before it leaves the device:
170
+
171
+ ```ts
172
+ import { PHOTO_LIMITS, PhotoError, isPhotoCandidate, preparePhoto } from "@fullgrainsoftware/portal";
173
+
174
+ // <input type="file" accept={PHOTO_LIMITS.accept} multiple> — at most PHOTO_LIMITS.perInquiry (5) photos
175
+ if (!isPhotoCandidate(file)) { /* not a photo — tell the visitor, skip it */ }
176
+ try {
177
+ const jpeg = await preparePhoto(file); // a JPEG File, ≤ 1568 px on the long side, EXIF (GPS included) dropped
178
+ const body = new FormData();
179
+ body.append("file", jpeg);
180
+ const res = await fetch("/api/contact/photo", { method: "POST", body }); // the site's route, one photo per request → { id } or an error word
181
+ } catch (err) {
182
+ if (err instanceof PhotoError) showOnTile(err.message); // err.code: "unsupported" | "unreadable" | "too_large"
183
+ }
184
+ ```
185
+
186
+ `preparePhoto(file, { maxEdge?, quality? })` decodes with the browser's own
187
+ decoder (keeping the photo's orientation), scales to fit `PHOTO_LIMITS.maxEdge`
188
+ (1568) without enlarging, and re-encodes as `<name>.jpg` at quality 0.85.
189
+ It is browser-only: on the server it rejects with `unsupported`. Every failure
190
+ is a `PhotoError` whose `message` is written for the visitor and safe to show
191
+ as is: `unsupported` (not a type it takes), `unreadable` (the browser couldn't
192
+ decode it, or the file is corrupt), `too_large` (still over
193
+ `PHOTO_LIMITS.maxUploadBytes`, 4 MiB, after the resize). `isPhotoCandidate` is
194
+ the same type check, for the picker's own validation: the
195
+ `PHOTO_LIMITS.accept` types (JPEG, PNG, WebP, GIF), the HEIC family, and a
196
+ file with no type (or `application/octet-stream`) whose name ends in one of
197
+ those extensions. A HEIC reaches the decoder: Safari and iOS decode it, other
198
+ browsers reject it as `unreadable`. `PHOTO_LIMITS.accept` leaves HEIC out on
199
+ purpose, so iOS converts to JPEG in the picker; the SDK ships no HEIC decoder.
200
+
201
+ On the server, the site's route forwards the file and the submit names the ids:
202
+
203
+ ```ts
204
+ // app/api/contact/photo/route.ts
205
+ import { PHOTO_LIMITS, createInquiries } from "@fullgrainsoftware/portal";
206
+
207
+ const form = await req.formData();
208
+ const file = form.get("file");
209
+ if (!(file instanceof Blob)) return badRequest();
210
+ if (file.size > PHOTO_LIMITS.maxUploadBytes) return tooLarge();
211
+ const apiKey = process.env.INQUIRY_API_KEY;
212
+ if (!apiKey?.startsWith("abik_")) return unavailable(); // 503 — "photos can't be added right now"
213
+ const inquiries = createInquiries({ apiKey }); // inside the handler, as for submit
214
+ const result = await inquiries.uploadPhoto({ file, meta: { ip, userAgent } }); // a File's own name is sent as `name`
215
+ // { ok: true, id, width, height, bytes }
216
+ // { ok: false, error: "invalid", issues: Record<string, string> } e.g. issues.file: "That photo couldn't be read."
217
+ // { ok: false, error: "too_large" | "unsupported_type" | "rate_limited" | "disabled" | "unavailable" }
218
+
219
+ // app/api/contact/route.ts — later, with the ids the browser kept, in display order
220
+ await inquiries.submit({ name, email, message, attachments: ids /* ≤ 5 */ });
221
+ ```
222
+
223
+ The panel re-checks every photo (by its own bytes, never the type the browser
224
+ claims), re-encodes it, stores it privately, and keeps an unattached upload for 24
225
+ hours: `submit` must name it within that window, or it answers `invalid` with
226
+ `issues.attachments` ("One of the photos expired. Add it again."; with
227
+ **Accept photos** off, "This site doesn't accept photos."); show that issue on
228
+ the photo strip. Removing a photo before sending just drops its id. Turn the
229
+ upload result into copy: `invalid` → the first text in `issues` (usually
230
+ `issues.file`; an over-long name is `issues.name`),
231
+ `too_large` → "That photo is too big", `unsupported_type` → "Use a JPEG or
232
+ PNG", `rate_limited` → "Try again in a few minutes" (20 uploads per visitor
233
+ per 10 minutes, 300 per site per hour), and `disabled`, `unavailable` or a
234
+ missing key → "Photos can't be added right now — send the message and email
235
+ them to us." **A photo failure never disables sending the message**: the form
236
+ submits with whichever photos succeeded. A `rate_limited` submit leaves the
237
+ photos attachable, so keep the ids and send them again on the retry.
238
+
239
+ Keep each request to one photo. The panel takes at most 4 MiB per file
240
+ (`uploadPhoto` sends whatever it is given; check the size in your route), and
241
+ Vercel refuses a function body over 4.5 MB with its own 413 before the code
242
+ runs, on your route as on the panel's (`uploadPhoto` maps any 413, JSON or
243
+ not, to `too_large`), so several photos in one request fail where one would
244
+ not.
245
+
246
+ Two rules every site keeps:
247
+
248
+ - **Never show a visitor's photo on the public site.** Photos are for the
249
+ business's inbox only; the panel serves them to signed-in users and nowhere
250
+ else.
251
+ - **Reviews refuse attachments.** A `form: "review"` submit with
252
+ `attachments` is `invalid` ("Photos can't be attached to a review."), so
253
+ leave the picker off review forms.
254
+
162
255
  ## Bookings (appointments)
163
256
 
164
257
  For sites whose business takes appointments. **Server-only** — the API key
package/dist/index.d.ts CHANGED
@@ -8,8 +8,10 @@ export type { CMSComponent, CMSPage, CMSReview, CMSSection, CMSSectionGroup, CMS
8
8
  export { createCommerce } from "./commerce.js";
9
9
  export type { CartPreview, CheckoutInput, CheckoutResult, Commerce, LineIssue, OrderStatus, ProductDetail, ProductSummary, StoreStatus, VariantView, } from "./commerce.js";
10
10
  export { createInquiries } from "./inquiries.js";
11
- export type { Inquiries, InquiryInput, InquirySubmitResult } from "./inquiries.js";
11
+ export type { Inquiries, InquiryInput, InquirySubmitResult, InquiryUploadInput, InquiryUploadResult, } from "./inquiries.js";
12
12
  export { initCmsPreview, normalizeSectionTitle, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
13
13
  export type { CmsPreviewOptions, PreviewInbound, PreviewOutbound } from "./preview.js";
14
+ export { PHOTO_LIMITS, PhotoError, isPhotoCandidate, preparePhoto } from "./photos.js";
15
+ export type { PhotoErrorCode, PreparePhotoOptions } from "./photos.js";
14
16
  export { createBookings } from "./bookings.js";
15
17
  export type { AvailabilityDay, AvailabilityResult, BookInput, BookResult, BookingCustomer, BookingService, BookingStatus, BookingView, Bookings, GetBookingResult, ManageResult, RequestTimeInput, RequestTimeResult, ServicesResult, } from "./bookings.js";
package/dist/index.js CHANGED
@@ -5,4 +5,5 @@ export { readReviews } from "./reviews.js";
5
5
  export { createCommerce } from "./commerce.js";
6
6
  export { createInquiries } from "./inquiries.js";
7
7
  export { initCmsPreview, normalizeSectionTitle, parseInbound, readyMessage, sectionClickedMessage, editItemMessage, PREVIEW_PROTOCOL_VERSION, PREVIEW_QUERY_FLAG, } from "./preview.js";
8
+ export { PHOTO_LIMITS, PhotoError, isPhotoCandidate, preparePhoto } from "./photos.js";
8
9
  export { createBookings } from "./bookings.js";
@@ -13,11 +13,37 @@ export interface InquiryInput {
13
13
  page?: string | null;
14
14
  /** A hidden field a human never fills; non-empty means a bot. */
15
15
  honeypot?: string | null;
16
+ /** Ids from `uploadPhoto`, in display order, at most 5. */
17
+ attachments?: string[];
16
18
  meta?: {
17
19
  ip?: string | null;
18
20
  userAgent?: string | null;
19
21
  };
20
22
  }
23
+ export interface InquiryUploadInput {
24
+ /** The photo: a browser `File` passed through, or any `Blob`. */
25
+ file: Blob;
26
+ /** The visitor's filename, shown in the inbox. Defaults to the `File`'s own name. */
27
+ name?: string;
28
+ meta?: {
29
+ ip?: string | null;
30
+ userAgent?: string | null;
31
+ };
32
+ }
33
+ export type InquiryUploadResult = {
34
+ ok: true;
35
+ id: string;
36
+ width: number;
37
+ height: number;
38
+ bytes: number;
39
+ } | {
40
+ ok: false;
41
+ error: "invalid";
42
+ issues: Record<string, string>;
43
+ } | {
44
+ ok: false;
45
+ error: "too_large" | "unsupported_type" | "rate_limited" | "disabled" | "unavailable";
46
+ };
21
47
  export type InquirySubmitResult = {
22
48
  ok: true;
23
49
  id: string;
@@ -31,6 +57,8 @@ export type InquirySubmitResult = {
31
57
  };
32
58
  export interface Inquiries {
33
59
  submit(input: InquiryInput): Promise<InquirySubmitResult>;
60
+ /** Stores one photo ahead of the submit; pass the returned id in `attachments`. */
61
+ uploadPhoto(input: InquiryUploadInput): Promise<InquiryUploadResult>;
34
62
  }
35
63
  export declare function createInquiries(opts: {
36
64
  apiKey: string;
package/dist/inquiries.js CHANGED
@@ -5,6 +5,8 @@
5
5
  into its own success, "try again in a few minutes", or fallback state. */
6
6
  const DEFAULT_BASE_URL = "https://app.fullgrainsoftware.com";
7
7
  const TIMEOUT_MS = 10_000;
8
+ /** An upload carries up to 4 MiB over the hop, so it gets longer than a submit. */
9
+ const UPLOAD_TIMEOUT_MS = 20_000;
8
10
  export function createInquiries(opts) {
9
11
  if (typeof window !== "undefined") {
10
12
  throw new Error("createInquiries is server-only — never expose INQUIRY_API_KEY to the browser");
@@ -14,6 +16,7 @@ export function createInquiries(opts) {
14
16
  }
15
17
  const base = (opts.baseUrl ?? process.env.AB_CMS_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
16
18
  const path = "/api/inquiries/submit";
19
+ const uploadPath = "/api/inquiries/uploads";
17
20
  const unavailable = (why) => {
18
21
  console.error(`[fullgrain inquiries] ${why}`);
19
22
  return { ok: false, error: "unavailable" };
@@ -48,5 +51,60 @@ export function createInquiries(opts) {
48
51
  return { ok: false, error: "rate_limited" };
49
52
  return unavailable(`unexpected response ${res.status}${body?.error ? ` (${body.error})` : ""} from ${base}${path}`);
50
53
  },
54
+ async uploadPhoto(input) {
55
+ const form = new FormData();
56
+ // A File carries its own name; an explicit `name` wins over it.
57
+ const name = input.name ?? (typeof input.file.name === "string" ? input.file.name : undefined);
58
+ if (name)
59
+ form.append("file", input.file, name);
60
+ else
61
+ form.append("file", input.file);
62
+ if (name)
63
+ form.append("name", name);
64
+ if (input.meta?.ip != null)
65
+ form.append("ip", input.meta.ip);
66
+ if (input.meta?.userAgent != null)
67
+ form.append("user_agent", input.meta.userAgent);
68
+ let res;
69
+ try {
70
+ // No Content-Type by hand: the runtime writes the multipart boundary.
71
+ res = await fetch(`${base}${uploadPath}`, {
72
+ method: "POST",
73
+ headers: { Authorization: `Bearer ${opts.apiKey}` },
74
+ body: form,
75
+ cache: "no-store",
76
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
77
+ });
78
+ }
79
+ catch (err) {
80
+ return unavailable(`request failed: ${err instanceof Error ? err.message : String(err)}`);
81
+ }
82
+ // Before the content-type check: above 4.5 MB Vercel answers its own HTML 413.
83
+ if (res.status === 413)
84
+ return { ok: false, error: "too_large" };
85
+ const contentType = res.headers.get("content-type") ?? "";
86
+ if (!contentType.includes("application/json")) {
87
+ return unavailable(`non-JSON response ${res.status} from ${base}${uploadPath}`);
88
+ }
89
+ const body = (await res.json().catch(() => null));
90
+ if (res.status === 200 && body?.ok && typeof body.id === "string") {
91
+ return {
92
+ ok: true,
93
+ id: body.id,
94
+ width: Number(body.width ?? 0),
95
+ height: Number(body.height ?? 0),
96
+ bytes: Number(body.bytes ?? 0),
97
+ };
98
+ }
99
+ if (res.status === 400 && body?.error === "invalid")
100
+ return { ok: false, error: "invalid", issues: body.issues ?? {} };
101
+ if (res.status === 403)
102
+ return { ok: false, error: "disabled" };
103
+ if (res.status === 415)
104
+ return { ok: false, error: "unsupported_type" };
105
+ if (res.status === 429)
106
+ return { ok: false, error: "rate_limited" };
107
+ return unavailable(`unexpected response ${res.status} from ${base}${uploadPath}`);
108
+ },
51
109
  };
52
110
  }
@@ -0,0 +1,37 @@
1
+ export declare const PHOTO_LIMITS: {
2
+ readonly perInquiry: 5;
3
+ readonly maxEdge: 1568;
4
+ readonly maxUploadBytes: number;
5
+ readonly accept: "image/jpeg,image/png,image/webp,image/gif";
6
+ };
7
+ export type PhotoErrorCode = "unsupported" | "unreadable" | "too_large";
8
+ export declare class PhotoError extends Error {
9
+ readonly code: PhotoErrorCode;
10
+ constructor(code: PhotoErrorCode, options?: {
11
+ cause?: unknown;
12
+ });
13
+ }
14
+ export interface PreparePhotoOptions {
15
+ /** Longest side of the result in pixels. Default PHOTO_LIMITS.maxEdge (1568); anything not a finite number ≥ 1 uses it. */
16
+ maxEdge?: number;
17
+ /** JPEG quality in (0, 1]. Default 0.85; anything outside that range uses it. */
18
+ quality?: number;
19
+ }
20
+ /**
21
+ * Whether a picked file is worth handing to the browser's decoder: one of
22
+ * PHOTO_LIMITS.accept's types, or the HEIC family — by type, or by name when
23
+ * the browser reports no type (an empty type or application/octet-stream):
24
+ * a .jpg/.jpeg/.png/.webp/.gif name (some Android cloud and document pickers
25
+ * report no type) or a .heic/.heif/.hif name (Windows Chrome gives a .heic an
26
+ * empty type). A stated non-image type wins over the name. Safari and iOS
27
+ * decode HEIC natively; elsewhere preparePhoto then rejects it as
28
+ * "unreadable". PHOTO_LIMITS.accept itself leaves HEIC out on purpose, so iOS
29
+ * keeps converting to JPEG in the picker.
30
+ * Sites can reuse this for their own picker validation.
31
+ */
32
+ export declare function isPhotoCandidate(file: Pick<File, "name" | "type">): boolean;
33
+ /**
34
+ * Resize a picked image to a JPEG of at most `maxEdge` px on its long side,
35
+ * without EXIF. Browser-only: on the server it rejects with "unsupported".
36
+ */
37
+ export declare function preparePhoto(file: File, options?: PreparePhotoOptions): Promise<File>;
package/dist/photos.js ADDED
@@ -0,0 +1,121 @@
1
+ // Browser helper for inquiry photos. A site calls preparePhoto in the
2
+ // visitor's browser before posting the picked file to its own route (which
3
+ // forwards it with inquiries.uploadPhoto). It decodes the image with the
4
+ // browser's own decoder, keeps the photo's orientation, scales it to at most
5
+ // PHOTO_LIMITS.maxEdge on the long side (never enlarging) and re-encodes it
6
+ // as a JPEG on a canvas filled white first, so transparency flattens onto
7
+ // white. Orientation is applied by the decoder: createImageBitmap's default
8
+ // is the photo's EXIF orientation. No imageOrientation option is passed, for
9
+ // browser compatibility: the "from-image" value exists only from Chrome 112,
10
+ // Firefox 111 and Safari 16, and an unknown enum value rejects the call. A
11
+ // HEIC is handed to the decoder too (see isPhotoCandidate): Safari and iOS
12
+ // decode it, other browsers reject it as "unreadable".
13
+ //
14
+ // The canvas re-encode carries no EXIF at all, so camera metadata — GPS
15
+ // coordinates included — never leaves the visitor's device.
16
+ //
17
+ // Failures reject with a PhotoError: a stable `code` for the site's logic and
18
+ // a `message` written for the visitor, safe to show as is (it never echoes
19
+ // the file's name or contents).
20
+ export const PHOTO_LIMITS = {
21
+ perInquiry: 5,
22
+ maxEdge: 1568,
23
+ maxUploadBytes: 4 * 1024 * 1024,
24
+ accept: "image/jpeg,image/png,image/webp,image/gif",
25
+ };
26
+ const MESSAGES = {
27
+ unsupported: "That file isn't a photo we can take. Use a JPEG or PNG.",
28
+ unreadable: "That photo couldn't be read. Try a JPEG or PNG.",
29
+ too_large: "That photo is too large.",
30
+ };
31
+ export class PhotoError extends Error {
32
+ code;
33
+ constructor(code, options) {
34
+ super(MESSAGES[code], options);
35
+ this.name = "PhotoError";
36
+ this.code = code;
37
+ }
38
+ }
39
+ const DEFAULT_QUALITY = 0.85;
40
+ const ACCEPTED_TYPES = PHOTO_LIMITS.accept.split(",");
41
+ const HEIC_TYPES = ["image/heic", "image/heif", "image/heic-sequence", "image/heif-sequence"];
42
+ const HEIC_NAME = /\.(heic|heif|hif)$/i;
43
+ const PHOTO_NAME = /\.(jpe?g|png|webp|gif)$/i;
44
+ /**
45
+ * Whether a picked file is worth handing to the browser's decoder: one of
46
+ * PHOTO_LIMITS.accept's types, or the HEIC family — by type, or by name when
47
+ * the browser reports no type (an empty type or application/octet-stream):
48
+ * a .jpg/.jpeg/.png/.webp/.gif name (some Android cloud and document pickers
49
+ * report no type) or a .heic/.heif/.hif name (Windows Chrome gives a .heic an
50
+ * empty type). A stated non-image type wins over the name. Safari and iOS
51
+ * decode HEIC natively; elsewhere preparePhoto then rejects it as
52
+ * "unreadable". PHOTO_LIMITS.accept itself leaves HEIC out on purpose, so iOS
53
+ * keeps converting to JPEG in the picker.
54
+ * Sites can reuse this for their own picker validation.
55
+ */
56
+ export function isPhotoCandidate(file) {
57
+ const type = file.type.toLowerCase();
58
+ if (ACCEPTED_TYPES.includes(type) || HEIC_TYPES.includes(type))
59
+ return true;
60
+ if (type === "" || type === "application/octet-stream")
61
+ return PHOTO_NAME.test(file.name) || HEIC_NAME.test(file.name);
62
+ return false;
63
+ }
64
+ /**
65
+ * Resize a picked image to a JPEG of at most `maxEdge` px on its long side,
66
+ * without EXIF. Browser-only: on the server it rejects with "unsupported".
67
+ */
68
+ export async function preparePhoto(file, options = {}) {
69
+ if (typeof document === "undefined")
70
+ throw new PhotoError("unsupported");
71
+ if (!isPhotoCandidate(file))
72
+ throw new PhotoError("unsupported");
73
+ let bitmap;
74
+ try {
75
+ bitmap = await createImageBitmap(file);
76
+ }
77
+ catch (cause) {
78
+ throw new PhotoError("unreadable", { cause });
79
+ }
80
+ let blob;
81
+ try {
82
+ const maxEdge = typeof options.maxEdge === "number" && Number.isFinite(options.maxEdge) && options.maxEdge >= 1
83
+ ? options.maxEdge
84
+ : PHOTO_LIMITS.maxEdge;
85
+ const quality = typeof options.quality === "number" && Number.isFinite(options.quality) && options.quality > 0 && options.quality <= 1
86
+ ? options.quality
87
+ : DEFAULT_QUALITY;
88
+ const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
89
+ const width = Math.max(1, Math.round(bitmap.width * scale));
90
+ const height = Math.max(1, Math.round(bitmap.height * scale));
91
+ const canvas = document.createElement("canvas");
92
+ canvas.width = width;
93
+ canvas.height = height;
94
+ const context = canvas.getContext("2d");
95
+ if (!context)
96
+ throw new PhotoError("unreadable");
97
+ // JPEG has no alpha: an unfilled canvas would encode transparent pixels as black.
98
+ context.fillStyle = "#ffffff";
99
+ context.fillRect(0, 0, width, height);
100
+ context.drawImage(bitmap, 0, 0, width, height);
101
+ const encoded = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", quality));
102
+ // Release the backing store now: Safari caps total canvas memory, and a
103
+ // site may prepare several photos at once.
104
+ canvas.width = canvas.height = 0;
105
+ if (!encoded)
106
+ throw new PhotoError("unreadable");
107
+ blob = encoded;
108
+ }
109
+ catch (cause) {
110
+ // A raw DOMException from createElement, getContext, drawImage or toBlob
111
+ // still reaches the site as a PhotoError, so its message is always safe to show.
112
+ throw cause instanceof PhotoError ? cause : new PhotoError("unreadable", { cause });
113
+ }
114
+ finally {
115
+ bitmap.close();
116
+ }
117
+ if (blob.size > PHOTO_LIMITS.maxUploadBytes)
118
+ throw new PhotoError("too_large");
119
+ const base = file.name.replace(/\.[^.]*$/, "") || "photo";
120
+ return new File([blob], `${base}.jpg`, { type: "image/jpeg", lastModified: file.lastModified });
121
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fullgrainsoftware/portal",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Fullgrain Portal site SDK: manifest, typed content reader, sync CLI, preview bridge, commerce, inquiries and bookings clients",
5
5
  "repository": {
6
6
  "type": "git",