Seed-packet scan UI: camera/upload → proposal → confirm (#102)
Build image / build-and-push (push) Successful in 6s
Build image / build-and-push (push) Successful in 6s
Adds the mobile-first UI for the seed-packet capture backend (live since #94): a capability-gated "Scan a packet" entry in the Plants catalog opens a camera/upload → editable proposal → confirm flow that creates a plant (new or matched) + a seed lot. Frontend only. Closes #102 — the last open child of epic #96. Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #119.
This commit is contained in:
+13
-6
@@ -42,7 +42,8 @@ export type Params = Record<string, ParamValue>
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
|
||||
/** JSON request body; serialized and sent with a JSON content-type. */
|
||||
/** Request body. A `FormData` goes out as multipart (a file upload — the
|
||||
* seed-packet scan); anything else is serialized as JSON. */
|
||||
body?: unknown
|
||||
/** Query-string parameters; undefined/null/'' entries are omitted. */
|
||||
params?: Params
|
||||
@@ -90,9 +91,13 @@ function messageFrom(body: unknown, status: number): string {
|
||||
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, params, signal } = opts
|
||||
|
||||
// Serialize before the try so a JSON.stringify failure (e.g. a circular value)
|
||||
// surfaces as itself, not as a misleading "cannot reach the server" error.
|
||||
const requestBody = body !== undefined ? JSON.stringify(body) : undefined
|
||||
// FormData must go out as multipart with a browser-generated boundary, so it's
|
||||
// sent as-is with NO content-type header (the browser sets it, boundary
|
||||
// included). Everything else is JSON — serialized before the try so a
|
||||
// JSON.stringify failure (e.g. a circular value) surfaces as itself, not as a
|
||||
// misleading "cannot reach the server" error.
|
||||
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
|
||||
const requestBody = body === undefined ? undefined : isForm ? body : JSON.stringify(body)
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
@@ -102,9 +107,9 @@ export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Prom
|
||||
credentials: 'same-origin', // send the HttpOnly session cookie
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
...(body !== undefined && !isForm ? { 'content-type': 'application/json' } : {}),
|
||||
},
|
||||
body: requestBody,
|
||||
body: requestBody as BodyInit | undefined,
|
||||
})
|
||||
} catch (err) {
|
||||
if ((err as Error)?.name === 'AbortError') throw err
|
||||
@@ -143,6 +148,8 @@ export const api = {
|
||||
apiFetch<T>(path, { ...opts, method: 'GET' }),
|
||||
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'POST', body }),
|
||||
postForm: <T>(path: string, form: FormData, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'POST', body: form }),
|
||||
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
|
||||
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
|
||||
|
||||
Reference in New Issue
Block a user