diff --git a/src/actions/water-log/field.ts b/src/actions/water-log/field.ts index 108963f..c4fed79 100644 --- a/src/actions/water-log/field.ts +++ b/src/actions/water-log/field.ts @@ -52,6 +52,16 @@ type FieldHeadgate = { high_threshold: number | null; low_threshold: number | null; active: boolean; + /** + * Free-text headgate description. Often a field location / section + * label (e.g. "Section 4 — North Field") or a coordinate string + * ("GPS 36.1234, -119.5678"). The mobile UI shows this as the + * card's secondary line — think of it as the "geocode" until a + * proper location column ships. + */ + notes: string | null; + /** ISO timestamp of the most recent successful log. Display only. */ + last_used_at: string | null; }; type VerifyPinResult = @@ -95,6 +105,8 @@ export async function getWaterHeadgates( high_threshold: h.highThreshold != null ? Number(h.highThreshold) : null, low_threshold: h.lowThreshold != null ? Number(h.lowThreshold) : null, active: h.active, + notes: h.notes, + last_used_at: h.lastUsedAt ? h.lastUsedAt.toISOString() : null, })); }); } diff --git a/src/app/globals.css b/src/app/globals.css index f734ad7..77be9b4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -302,6 +302,37 @@ select:-webkit-autofill:focus { to { transform: rotate(360deg); } } +/* Mobile Water Log — PIN caret blink. The `to { opacity: 0 }` + + * `steps(2, start)` combo gives a hard on/off blink like UISegmentedControl + * / UITextField, not a gentle fade. */ +@keyframes water-blink { + to { opacity: 0; } +} + +/* Mobile Water Log — PIN-entry error shake. Three diminishing + * oscillations for a "no, that wasn't right" feel. */ +@keyframes water-shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-10px); } + 40% { transform: translateX(10px); } + 60% { transform: translateX(-7px); } + 80% { transform: translateX(7px); } +} + +/* Mobile Water Log — checkmark stroke draw for the success screen. + * The element's `stroke-dasharray` is set inline so it works for any + * length; the keyframe just controls the timing. */ +@keyframes water-draw { + from { stroke-dashoffset: var(--draw-from, 100); } + to { stroke-dashoffset: 0; } +} + +/* Mobile Water Log — circular progress for the success ring. */ +@keyframes water-ring { + from { stroke-dashoffset: var(--ring-from, 360); } + to { stroke-dashoffset: 0; } +} + /* Reduce motion: respect the user's OS-level preference. */ /* The motion pass deliberately keeps the visual design intact (colors, type, layout) * but flattens everything that fights vestibular comfort. This block wipes: diff --git a/src/app/water/page.tsx b/src/app/water/page.tsx index 7a1cbe2..fd23f1f 100644 --- a/src/app/water/page.tsx +++ b/src/app/water/page.tsx @@ -1,5 +1,49 @@ +/** + * Public `/water` route — entrypoint for the mobile-first Tuxedo + * Water Log PIN-gated experience. + * + * As of 2026-07, the route renders `WaterFieldClient`, which now + * delegates to the Apple-HIG styled `MobileWaterApp` state machine + * (PIN → Headgates → Log → Success). All screens are designed + * for phones and tablets in the field — there is no desktop + * consideration. + * + * `display: "standalone"` + `viewportFit: "cover"` in the root + * layout, combined with the mobile-specific `` tags below, + * give this route a near-native feel when added to an iOS home + * screen as a PWA. + */ +import type { Metadata, Viewport } from "next"; import WaterFieldClient from "@/components/water/WaterFieldClient"; +export const metadata: Metadata = { + title: "Water Log · Tuxedo Ditch Co.", + description: + "Field log of headgate water readings — PIN-protected, mobile-only.", + robots: { + index: false, // PIN-gated field tool — no public indexing + follow: false, + }, + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Water Log", + }, +}; + +// The water route is mobile-only — never scale above 1.0 (we don't +// want desktop users pinching-and-zooming a tool that's tuned for +// thumb-reach). The viewport-fit: cover setting in the root layout +// enables safe-area insets on iOS. +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + maximumScale: 1, + userScalable: false, + themeColor: "#14532d", + viewportFit: "cover", +}; + export default async function WaterPage() { return ; } \ No newline at end of file diff --git a/src/components/water/WaterFieldClient.tsx b/src/components/water/WaterFieldClient.tsx index 6312ae4..0aa829f 100644 --- a/src/components/water/WaterFieldClient.tsx +++ b/src/components/water/WaterFieldClient.tsx @@ -1,1006 +1,51 @@ "use client"; -import Image from "next/image"; -import { useReducer, useEffect, Suspense, useMemo, useRef } from "react"; -import { - verifyWaterPin, - submitWaterEntry, - getWaterHeadgates, - logoutWater, - setWaterLang, -} from "@/actions/water-log/field"; -import { useSearchParams } from "next/navigation"; -import { WaterGauge } from "@/components/water/icons"; - -type Headgate = { - id: string; - name: string; - active: boolean; - high_threshold?: number | null; - low_threshold?: number | null; -}; - -type Language = "en" | "es"; - -type Step = "loading" | "lang" | "role" | "pin" | "form"; - -type GpsStatus = "idle" | "capturing" | "success" | "error" | "denied"; - -const TUXEDO_BRAND_ID = "64294306-5f42-463d-a5e8-2ad6c81a96de"; - -const UNITS = ["CFS", "GPM", "Inches", "AF/Day"]; - -const LABELS = { - en: { - title: "Water Log", - selectLang: "Select Language", - whoAreYou: "Who are you?", - enterPin: "Enter your 4-digit PIN", - pinPlaceholder: "PIN", - submit: "Submit Reading", - loggingIn: "Verifying...", - selectHeadgate: "Select Headgate", - measurement: "Measurement", - notes: "Notes (optional)", - notesPlaceholder: "Notes...", - unit: "Unit", - loggedInAs: "Logged in as", - logout: "Logout", - submitting: "Submitting...", - success: "Reading submitted!", - error: "Error", - noHeadgates: "No headgates available", - invalidPin: "Invalid PIN", - sessionExpired: "Session expired", - back: "Back", - captureGps: "Capture GPS", - capturingGps: "Capturing...", - gpsSuccess: "GPS Captured", - gpsError: "GPS unavailable", - gpsDenied: "Location denied. Tap to retry.", - photoLabel: "Photo (optional)", - addPhoto: "Add Photo", - removePhoto: "Remove", - headgateLocked: "QR-locked", - locked: "Locked", - highAlert: "High", - lowAlert: "Low", - }, - es: { - title: "Registro de Agua", - selectLang: "Seleccionar Idioma", - whoAreYou: "¿Quién eres?", - enterPin: "Ingrese su PIN de 4 dígitos", - pinPlaceholder: "PIN", - submit: "Enviar Lectura", - loggingIn: "Verificando...", - selectHeadgate: "Seleccionar Compuerta", - measurement: "Medición", - notes: "Notas (opcional)", - notesPlaceholder: "Notas...", - unit: "Unidad", - loggedInAs: "Conectado como", - logout: "Salir", - submitting: "Enviando...", - success: "¡Lectura registrada!", - error: "Error", - noHeadgates: "No hay compuertas disponibles", - invalidPin: "PIN inválido", - sessionExpired: "Sesión expirada", - back: "Volver", - captureGps: "Capturar GPS", - capturingGps: "Capturando...", - gpsSuccess: "GPS Capturado", - gpsError: "GPS no disponible", - gpsDenied: "Ubicación denegada. Toca para reintentar.", - photoLabel: "Foto (opcional)", - addPhoto: "Agregar Foto", - removePhoto: "Quitar", - headgateLocked: "Bloqueada por QR", - locked: "Bloqueada", - highAlert: "Alto", - lowAlert: "Bajo", - }, -}; - -// ────────────────────────────────────────────────────────────────────────── -// State (useReducer) -// ────────────────────────────────────────────────────────────────────────── - -type State = { - step: Step; - lang: Language; - pin: string; - irrigatorName: string; - headgates: Headgate[]; - selectedHeadgate: string; - headgateLocked: boolean; - measurement: string; - unit: string; - notes: string; - loading: boolean; - error: string; - success: boolean; - photoPreview: string | null; - latitude: number | null; - longitude: number | null; - gpsStatus: GpsStatus; -}; - -type Action = - | { type: "SET_STEP"; value: Step } - | { type: "SET_LANG"; value: Language } - | { type: "SET_PIN"; value: string } - | { type: "SET_IRRIGATOR_NAME"; value: string } - | { type: "SET_HEADGATES"; value: Headgate[] } - | { type: "SET_SELECTED_HEADGATE"; value: string } - | { type: "SET_HEADGATE_LOCKED"; value: boolean } - | { type: "SET_MEASUREMENT"; value: string } - | { type: "SET_UNIT"; value: string } - | { type: "SET_NOTES"; value: string } - | { type: "SET_LOADING"; value: boolean } - | { type: "SET_ERROR"; value: string } - | { type: "SET_SUCCESS"; value: boolean } - | { type: "SET_PHOTO_PREVIEW"; value: string | null } - | { type: "SET_LATITUDE"; value: number | null } - | { type: "SET_LONGITUDE"; value: number | null } - | { type: "SET_GPS_STATUS"; value: GpsStatus } - | { type: "RESET_FORM_FIELDS" } - | { type: "RESET_FULL" }; - -function makeInitialState(): State { - // Read cookie preferences on first render. Component is client-only via - // , so accessing document.cookie in the lazy initializer is safe. - if (typeof document === "undefined") { - return { - step: "loading", - lang: "en", - pin: "", - irrigatorName: "", - headgates: [], - selectedHeadgate: "", - headgateLocked: false, - measurement: "", - unit: "CFS", - notes: "", - loading: false, - error: "", - success: false, - photoPreview: null, - latitude: null, - longitude: null, - gpsStatus: "idle", - }; - } - const savedLang = document.cookie.match(/wl_lang=(en|es)/)?.[1]; - const hasSession = !!document.cookie.match(/wl_session=([^;]+)/); - return { - step: hasSession ? "form" : "loading", - lang: (savedLang as Language) || "en", - pin: "", - irrigatorName: "", - headgates: [], - selectedHeadgate: "", - headgateLocked: false, - measurement: "", - unit: "CFS", - notes: "", - loading: false, - error: "", - success: false, - photoPreview: null, - latitude: null, - longitude: null, - gpsStatus: "idle", - }; -} - -function reducer(state: State, action: Action): State { - switch (action.type) { - case "SET_STEP": - return { ...state, step: action.value }; - case "SET_LANG": - return { ...state, lang: action.value }; - case "SET_PIN": - return { ...state, pin: action.value }; - case "SET_IRRIGATOR_NAME": - return { ...state, irrigatorName: action.value }; - case "SET_HEADGATES": - return { ...state, headgates: action.value }; - case "SET_SELECTED_HEADGATE": - return { ...state, selectedHeadgate: action.value }; - case "SET_HEADGATE_LOCKED": - return { ...state, headgateLocked: action.value }; - case "SET_MEASUREMENT": - return { ...state, measurement: action.value }; - case "SET_UNIT": - return { ...state, unit: action.value }; - case "SET_NOTES": - return { ...state, notes: action.value }; - case "SET_LOADING": - return { ...state, loading: action.value }; - case "SET_ERROR": - return { ...state, error: action.value }; - case "SET_SUCCESS": - return { ...state, success: action.value }; - case "SET_PHOTO_PREVIEW": - return { ...state, photoPreview: action.value }; - case "SET_LATITUDE": - return { ...state, latitude: action.value }; - case "SET_LONGITUDE": - return { ...state, longitude: action.value }; - case "SET_GPS_STATUS": - return { ...state, gpsStatus: action.value }; - case "RESET_FORM_FIELDS": - return { - ...state, - measurement: "", - notes: "", - photoPreview: null, - latitude: null, - longitude: null, - gpsStatus: "idle", - }; - case "RESET_FULL": - return { - ...state, - step: "lang", - pin: "", - irrigatorName: "", - headgates: [], - selectedHeadgate: "", - measurement: "", - notes: "", - photoPreview: null, - latitude: null, - longitude: null, - gpsStatus: "idle", - headgateLocked: false, - }; - } -} - -function ThresholdBadge({ high, low, t }: { high?: number | null; low?: number | null; t: typeof LABELS.en }) { - if (!high && !low) return null; - return ( -
- {high != null && ( - - ⚠️ {t.highAlert}: {high} - - )} - {low != null && ( - - ⚠️ {t.lowAlert}: {low} - - )} -
- ); -} - -function WaterFieldInner() { - const searchParams = useSearchParams(); - const qrHeadgateToken = searchParams.get("h"); - - const [state, dispatch] = useReducer(reducer, undefined, makeInitialState); - const photoFileRef = useRef(null); - - const t = LABELS[state.lang]; - - // On mount, if wl_session cookie is present, load headgates so the - // form is populated by the time the user lands. The step itself is - // already set in the lazy initializer above. - useEffect(() => { - if (typeof document === "undefined") return; - const hasSession = document.cookie.match(/wl_session=([^;]+)/); - if (hasSession) { - void loadHeadgates(); - } - }, []); - - // QR-locked headgate: derive during render instead of syncing in an effect. - // When qrHeadgateToken matches a loaded headgate, override the user's - // selection and lock the dropdown. Otherwise fall back to whatever the - // user picked (or none). - const qrMatchedHeadgate = useMemo(() => { - if (!qrHeadgateToken || state.headgates.length === 0) return null; - return ( - state.headgates.find((hg) => hg.id === qrHeadgateToken || hg.name === qrHeadgateToken) ?? null - ); - }, [qrHeadgateToken, state.headgates]); - - const effectiveSelectedHeadgate = qrMatchedHeadgate?.id ?? state.selectedHeadgate; - const isHeadgateLocked = qrMatchedHeadgate ? true : state.headgateLocked; - - async function loadHeadgates() { - const hgs = await getWaterHeadgates(TUXEDO_BRAND_ID, true); - dispatch({ type: "SET_HEADGATES", value: hgs.filter((h) => h.active) }); - } - - // Show loading spinner next to headgate dropdown - const isLoadingHeadgates = state.step === "form" && state.headgates.length === 0; - - async function handleLangSelect(lang: Language) { - dispatch({ type: "SET_LANG", value: lang }); - await setWaterLang(lang); - dispatch({ type: "SET_STEP", value: "role" }); - dispatch({ type: "SET_ERROR", value: "" }); - } - - async function handlePinSubmit(e: React.FormEvent) { - e.preventDefault(); - if (state.pin.length !== 4) return; - dispatch({ type: "SET_LOADING", value: true }); - dispatch({ type: "SET_ERROR", value: "" }); - - const result = await verifyWaterPin(TUXEDO_BRAND_ID, state.pin); - - if (!result.success) { - dispatch({ type: "SET_ERROR", value: result.error === "Invalid PIN" ? LABELS[state.lang].invalidPin : result.error }); - dispatch({ type: "SET_PIN", value: "" }); - dispatch({ type: "SET_LOADING", value: false }); - return; - } - - if (result.role === "water_admin") { - window.location.href = "/water/admin"; - return; - } - - dispatch({ type: "SET_IRRIGATOR_NAME", value: result.name ?? "" }); - await loadHeadgates(); - dispatch({ type: "SET_STEP", value: "form" }); - dispatch({ type: "SET_LOADING", value: false }); - } - - async function handleCaptureGps() { - if (!navigator.geolocation) { - dispatch({ type: "SET_GPS_STATUS", value: "error" }); - return; - } - dispatch({ type: "SET_GPS_STATUS", value: "capturing" }); - navigator.geolocation.getCurrentPosition( - (pos) => { - dispatch({ type: "SET_LATITUDE", value: pos.coords.latitude }); - dispatch({ type: "SET_LONGITUDE", value: pos.coords.longitude }); - dispatch({ type: "SET_GPS_STATUS", value: "success" }); - }, - (err) => { - if (err.code === err.PERMISSION_DENIED) { - dispatch({ type: "SET_GPS_STATUS", value: "denied" }); - } else { - dispatch({ type: "SET_GPS_STATUS", value: "error" }); - } - }, - { enableHighAccuracy: true, timeout: 10000 } - ); - } - - function handlePhotoChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; - if (file.size > 5 * 1024 * 1024) { - dispatch({ type: "SET_ERROR", value: "Photo must be under 5MB" }); - return; - } - if (!["image/jpeg", "image/png"].includes(file.type)) { - dispatch({ type: "SET_ERROR", value: "Only JPG or PNG photos allowed" }); - return; - } - photoFileRef.current = file; - const reader = new FileReader(); - reader.onload = (ev) => dispatch({ type: "SET_PHOTO_PREVIEW", value: ev.target?.result as string }); - reader.readAsDataURL(file); - } - - function removePhoto() { - photoFileRef.current = null; - dispatch({ type: "SET_PHOTO_PREVIEW", value: null }); - } - - async function handleSubmitEntry(e: React.FormEvent) { - e.preventDefault(); - if (!effectiveSelectedHeadgate || !state.measurement) return; - dispatch({ type: "SET_LOADING", value: true }); - dispatch({ type: "SET_ERROR", value: "" }); - - let photoUrl: string | undefined; - if (photoFileRef.current) { - const formData = new FormData(); - formData.append("file", photoFileRef.current); - formData.append("bucket", "water-log-photos"); - try { - const uploadRes = await fetch("/api/water-photo-upload", { - method: "POST", - body: formData, - }); - const uploadData = await uploadRes.json(); - if (uploadData.url) { - photoUrl = uploadData.url; - } - } catch { - // photo upload failed — submit without photo - } - } - - const result = await submitWaterEntry( - effectiveSelectedHeadgate, - parseFloat(state.measurement), - state.unit, - state.notes, - photoUrl, - state.latitude ?? undefined, - state.longitude ?? undefined, - isHeadgateLocked - ); - - if (!result.success) { - if (result.error === "Session expired or invalid" || result.error === "Not logged in") { - dispatch({ type: "SET_STEP", value: "lang" }); - dispatch({ type: "SET_PIN", value: "" }); - dispatch({ type: "SET_ERROR", value: LABELS[state.lang].sessionExpired }); - } else { - dispatch({ type: "SET_ERROR", value: result.error }); - } - dispatch({ type: "SET_LOADING", value: false }); - return; - } - - dispatch({ type: "SET_SUCCESS", value: true }); - dispatch({ type: "RESET_FORM_FIELDS" }); - photoFileRef.current = null; - dispatch({ type: "SET_LOADING", value: false }); - - setTimeout(() => dispatch({ type: "SET_SUCCESS", value: false }), 3000); - } - - async function handleLogout() { - await logoutWater(); - dispatch({ type: "RESET_FULL" }); - } - - if (state.step === "loading") { - return ; - } - - if (state.step === "lang") { - return ; - } - - if (state.step === "role") { - return ( - dispatch({ type: "SET_STEP", value: "pin" })} - onBack={() => dispatch({ type: "SET_STEP", value: "lang" })} - /> - ); - } - - if (state.step === "pin") { - return ( - dispatch({ type: "SET_PIN", value: v })} - onBack={() => dispatch({ type: "SET_STEP", value: "role" })} - onSubmit={handlePinSubmit} - /> - ); - } - - // Main log form - const selectedHg = state.headgates.find((hg) => hg.id === effectiveSelectedHeadgate); - - return ( - dispatch({ type: "SET_MEASUREMENT", value: v })} - onSetUnit={(v) => dispatch({ type: "SET_UNIT", value: v })} - onSetNotes={(v) => dispatch({ type: "SET_NOTES", value: v })} - onSetSelectedHeadgate={(v) => dispatch({ type: "SET_SELECTED_HEADGATE", value: v })} - onCaptureGps={handleCaptureGps} - onPhotoChange={handlePhotoChange} - onRemovePhoto={removePhoto} - onSubmit={handleSubmitEntry} - onLogout={handleLogout} - /> - ); -} - -// ── LoadingScreen subcomponent ────────────────────────────────────────────── - -function LoadingScreen() { - return ( -
-
-
- -
-
-

Loading…

-
-
- ); -} - -// ── LanguageSelectionScreen subcomponent ──────────────────────────────────── - -function LanguageSelectionScreen({ - t, - onSelect, -}: { - t: typeof LABELS.en; - onSelect: (lang: Language) => void; -}) { - return ( -
-
-
- -
-

- {t.title} -

-

- Tuxedo Ditch Co. -

-

{t.selectLang}

-
- - -
-
-
- ); -} - -// ── RoleSelectionScreen subcomponent ──────────────────────────────────────── - -function RoleSelectionScreen({ - t, - onSelectRole, - onBack, -}: { - t: typeof LABELS.en; - onSelectRole: () => void; - onBack: () => void; -}) { - return ( -
-
- -

- {t.title} -

-

{t.whoAreYou}

-
- - -
-
-
- ); -} - -// ── PinEntryScreen subcomponent ───────────────────────────────────────────── - -function PinEntryScreen({ - t, - pin, - loading, - error, - onSetPin, - onBack, - onSubmit, -}: { - t: typeof LABELS.en; - pin: string; - loading: boolean; - error: string; - onSetPin: (v: string) => void; - onBack: () => void; - onSubmit: (e: React.FormEvent) => void; -}) { - return ( -
-
- -

- {t.title} -

-

{t.enterPin}

- -
- onSetPin(e.target.value.replace(/\D/g, "").slice(0, 4))} - placeholder={t.pinPlaceholder} - className="w-full rounded-xl border-2 border-[#d4d9d3] bg-white px-6 py-6 text-center text-4xl font-bold tracking-widest text-[#1d1d1f] outline-none focus:border-[#1a4d2e] mb-4" - style={{ letterSpacing: "0.3em" }} - /> - {error && ( -

{error}

- )} - -
-
-
- ); -} - -// ── WaterLogForm subcomponent ─────────────────────────────────────────────── - -function WaterLogForm({ - t, - step, - loading, - error, - success, - irrigatorName, - measurement, - unit, - notes, - latitude, - longitude, - gpsStatus, - photoPreview, - headgates, - selectedHeadgate, - isHeadgateLocked, - isLoadingHeadgates, - selectedHg, - onSetMeasurement, - onSetUnit, - onSetNotes, - onSetSelectedHeadgate, - onCaptureGps, - onPhotoChange, - onRemovePhoto, - onSubmit, - onLogout, -}: { - t: typeof LABELS.en; - step: Step; - loading: boolean; - error: string; - success: boolean; - irrigatorName: string; - measurement: string; - unit: string; - notes: string; - latitude: number | null; - longitude: number | null; - gpsStatus: GpsStatus; - photoPreview: string | null; - headgates: Headgate[]; - selectedHeadgate: string; - isHeadgateLocked: boolean; - isLoadingHeadgates: boolean; - selectedHg: Headgate | null; - onSetMeasurement: (v: string) => void; - onSetUnit: (v: string) => void; - onSetNotes: (v: string) => void; - onSetSelectedHeadgate: (v: string) => void; - onCaptureGps: () => void; - onPhotoChange: (e: React.ChangeEvent) => void; - onRemovePhoto: () => void; - onSubmit: (e: React.FormEvent) => void; - onLogout: () => void; -}) { - return ( -
- {/* Header */} -
-
-
- -
-

- {t.title} -

-

- {t.loggedInAs}: {irrigatorName} -

-
-
- -
-
- - {/* Step progress indicator */} -
-
-
- {["lang", "role", "pin", "form"].map((s, i) => { - const stepIndex = ["lang", "role", "pin", "form"].indexOf(step as string); - const isActive = step === s; - const isPast = stepIndex > i; - return ( -
-
- {i < 3 &&
} -
- ); - })} -
-
-
- -
- {/* Success banner */} - {success && ( -
- ✓ {t.success} -
- )} - - {/* Error banner */} - {error && !success && ( -
- {t.error}: {error} -
- )} - - {/* Headgate selector */} -
- - {isHeadgateLocked ? ( -
-
- - - - {selectedHg?.name ?? selectedHeadgate} - - 🔒 {t.locked} - -
- {selectedHg && ( - - )} -
- ) : isLoadingHeadgates ? ( -
-
- Loading headgates... -
- ) : ( - - )} - {selectedHg && !isHeadgateLocked && ( - - )} -
- - {/* Measurement + Unit row */} -
-
- - onSetMeasurement(e.target.value)} - required - placeholder="0.00" - className="w-full rounded-xl border-2 border-stone-300 px-4 py-4 text-2xl font-bold text-stone-900 outline-none focus:border-stone-900 min-h-[60px]" - inputMode="decimal" - /> -
-
- - -
-
- - {/* GPS Capture */} -
- - -
- - {/* Photo Upload */} -
- - {photoPreview ? ( -
- Preview - -
- ) : ( - - )} -
- - {/* Notes */} -
- -