Ctrl K
ComunidadGuías…

src/components/checkin/checkin-details-form.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"use client";

import { type ReactNode, useState } from "react";
import {
  useForm,
  useWatch,
  type Control,
  type Resolver,
} from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react";

import { useAuth } from "@/contexts/AuthContext";
import { useCheckinDetails } from "@/contexts/CheckinDetailsContext";
import { useEffectOnChange } from "@/hooks/useEffectOnChange";
import { usePictureEdit } from "@/hooks/usePictureEdit";
import { authAPI } from "@/lib/api/auth";
import type { CheckinDetails } from "@/lib/api/checkin-details";
import { getApiErrorMessage } from "@/lib/api/error";
import { dialogFormSubmit } from "@/lib/dialog-form";
import { PICTURE_LABEL, type PictureKind } from "@/lib/picture";
import { applyPictureEdit } from "@/lib/picture-edits";
import {
  CHECKIN_GROUPS,
  checkinDetailsPatch,
  checkinDetailsSchema,
  checkinFormValues,
  type CheckinDetailsFormValues,
  type CheckinGroup,
  type CheckinMemberKey,
} from "@/lib/validations/checkin-details";
import { USER_FIELD_SCHEMAS } from "@/lib/validations/user-fields";
import {
  EmergencyContactsField,
  InsurancePoliciesField,
  listFieldErrors,
} from "@/components/checkin/checkin-list-field";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { FormApiError } from "@/components/ui/form-api-error";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/components/ui/use-toast";
import { PictureField } from "@/components/user/picture-field";
import { UserFieldsSubmitButton } from "@/components/user/user-fields-form";

/**
 * What each check-in group is called, and the line under it, on every surface that
 * gives a group a heading of its own: a `/checkin` dialog, a `/settings` card, the
 * bell's insurance dialog.
 */
export const CHECK_IN_GROUP_HEADINGS: Record<
  CheckinGroup,
  { title: string; description: string }
> = {
  about: {
    title: "About You",
    description: "Your own details, as a desk asks for them.",
  },
  insurance: {
    title: "Dive Insurance",
    description:
      "Each policy a shop takes down: the provider, the policy number, and when the cover runs out.",
  },
  emergency: {
    title: "Emergency Contacts",
    description:
      "Who a shop calls if something goes wrong, and how they know you. The first is called first.",
  },
};

// How each scalar member is written on screen. They describe the diver, so the
// phone and email boxes say so to the browser's autofill.
const SCALAR_SPECS = {
  date_of_birth: { label: "Date of birth", kind: "date" },
  phone: { label: "Phone number", kind: "tel", autoComplete: "tel" },
  email: {
    label: "Email",
    kind: "email",
    autoComplete: "email",
    description:
      "Printed on your check-in sheet and carried in your exports. The address you sign in with stays private.",
  },
} as const satisfies Record<
  "date_of_birth" | "phone" | "email",
  {
    label: string;
    kind: "date" | "tel" | "email";
    autoComplete?: string;
    description?: string;
  }
>;

/** The sheet's About You dialog edits the account's name beside the object. */
type CheckinFormValues = CheckinDetailsFormValues & { name: string };

/**
 * One member of the check-in details as every form over them writes it: a scalar as a
 * labelled box, a list as its row editor. `hideLabel` keeps the label for a screen
 * reader only, for a host whose legend already says it.
 */
export function CheckinMemberField({
  member,
  control,
  hideLabel = false,
}: {
  member: CheckinMemberKey;
  control: Control<CheckinDetailsFormValues>;
  hideLabel?: boolean;
}) {
  if (member === "emergency_contacts" || member === "insurance_policies") {
    const label =
      member === "emergency_contacts"
        ? "Emergency contacts"
        : "Insurance policies";
    return (
      <FormField
        control={control}
        name={member}
        render={({ field, fieldState }) => (
          <FormItem>
            <FormLabel className={hideLabel ? "sr-only" : undefined}>
              {label}
            </FormLabel>
            {/* No `FormMessage`: a failing row leaves the list's own error an array,
                which it would print as the word "undefined". The field shows each
                row's message on the row. */}
            <FormControl
              render={
                member === "emergency_contacts" ? (
                  <EmergencyContactsField
                    value={
                      field.value as CheckinDetailsFormValues[typeof member]
                    }
                    onChange={field.onChange}
                    errors={listFieldErrors(fieldState.error)}
                  />
                ) : (
                  <InsurancePoliciesField
                    value={
                      field.value as CheckinDetailsFormValues[typeof member]
                    }
                    onChange={field.onChange}
                    errors={listFieldErrors(fieldState.error)}
                  />
                )
              }
            />
          </FormItem>
        )}
      />
    );
  }

  const spec: {
    label: string;
    kind: string;
    autoComplete?: string;
    description?: string;
  } = SCALAR_SPECS[member];
  return (
    <FormField
      control={control}
      name={member}
      render={({ field }) => (
        <FormItem>
          <FormLabel className={hideLabel ? "sr-only" : undefined}>
            {spec.label}
          </FormLabel>
          <FormControl
            render={
              spec.kind === "date" ? (
                <DatePicker value={field.value} onChange={field.onChange} />
              ) : (
                <Input
                  type={spec.kind}
                  autoComplete={spec.autoComplete}
                  {...field}
                />
              )
            }
          />
          {spec.description && (
            <FormDescription>{spec.description}</FormDescription>
          )}
          <FormMessage />
        </FormItem>
      )}
    />
  );
}

export interface CheckinDetailsFormProps {
  group: CheckinGroup;
  /** The sheet's About You: the account's name, sent to `PATCH /user` when changed. */
  withName?: boolean;
  /** The portrait, edited above the fields and sent after them. */
  picture?: PictureKind;
  /**
   * The footer this host puts under the fields - a settings card's full-width
   * button, a dialog's Cancel/Save row - inside the `<form>` and its provider.
   */
  children: ReactNode;
  /** Ran once the save has landed, picture included, which is what closes a dialog. */
  onSaved?: () => void;
  savedMessage?: string;
}

/**
 * One group of the check-in details, on every surface that edits one: a `/settings`
 * card, a `/checkin` dialog, the bell's insurance dialog. Each is this component with a
 * different `group`, so a member cannot be worded, bounded or cleared differently
 * depending on where it was edited.
 *
 * Nothing is offered until the shared copy has loaded - a form seeded from a copy that
 * never arrived would send its group empty and clear it - so until then this is a
 * placeholder, and after a failed read it says so and offers the read again.
 */
export function CheckinDetailsForm(props: CheckinDetailsFormProps) {
  const { details, loadFailed, reload } = useCheckinDetails();

  if (!details) {
    return loadFailed ? (
      <div className="space-y-3">
        <p className="flex items-start gap-2 text-sm text-muted-foreground">
          <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
          Your check-in details didn&rsquo;t load.
        </p>
        <Button type="button" variant="outline" size="sm" onClick={reload}>
          Try again
        </Button>
      </div>
    ) : (
      <div
        role="status"
        aria-label="Loading your check-in details"
        className="space-y-3"
      >
        <Skeleton className="h-4 w-24" />
        <Skeleton className="h-9 w-full" />
        <Skeleton className="h-9 w-full" />
      </div>
    );
  }

  return <LoadedForm {...props} details={details} />;
}

function LoadedForm({
  group,
  withName = false,
  picture,
  children,
  onSaved,
  savedMessage = "Your check-in details are up to date.",
  details,
}: CheckinDetailsFormProps & { details: CheckinDetails }) {
  const { user, refreshUser } = useAuth();
  const { save } = useCheckinDetails();
  const { toast } = useToast();
  const [error, setError] = useState<string | null>(null);
  const [pictureEdit, setPictureEdit] = usePictureEdit();

  const members = CHECKIN_GROUPS[group];
  const stored: CheckinFormValues = {
    ...checkinFormValues(details),
    name: user?.name ?? "",
  };

  const form = useForm<CheckinFormValues>({
    // Through `unknown`: a schema assembled from a runtime list infers an index
    // signature, and `useForm<CheckinFormValues>` is what keeps the names checked.
    resolver: zodResolver(
      withName
        ? checkinDetailsSchema(members).extend({
            name: USER_FIELD_SCHEMAS.name,
          })
        : checkinDetailsSchema(members),
    ) as unknown as Resolver<CheckinFormValues>,
    defaultValues: stored,
  });

  // Repaints from what the save answered, keyed on this form's own group and its own
  // saves rather than on the whole object: a sibling form saving its group replaces
  // the shared copy, and what is half-typed here must survive that. `saves` covers a
  // save that changed nothing, a trimmed value say. `useEffectOnChange` so a route
  // shown again does not paint over a half-filled form.
  const { reset } = form;
  const [saves, setSaves] = useState(0);
  const storedHere = JSON.stringify([
    ...members.map((member) => stored[member]),
    withName ? stored.name : null,
  ]);
  useEffectOnChange(() => reset(stored), [storedHere, saves, reset]);

  const onSubmit = async () => {
    setError(null);
    const values = form.getValues();
    const name = values.name.trim();
    const nameChanged = withName && !!user && name !== user.name;
    const sending = picture && pictureEdit ? { picture, pictureEdit } : null;
    try {
      // The name first, as `UserFieldsForm` sends its fields before a picture: a
      // name the account refuses stops the save before anything else is written.
      if (nameChanged) await authAPI.updateProfile({ name });
      // Beside a pending picture, skipped when it would write what is stored, as in
      // `UserFieldsForm`, so the Save after a failed picture sends the picture alone.
      const patch = checkinDetailsPatch(members, values);
      if (
        !sending ||
        JSON.stringify(patch) !==
          JSON.stringify(checkinDetailsPatch(members, stored))
      ) {
        await save(patch);
      }
      let pictureError: unknown = null;
      if (sending) {
        // Cleared only once it lands, as in `UserFieldsForm`: a failed picture stays
        // picked for the next Save to retry.
        try {
          await applyPictureEdit(sending.picture, sending.pictureEdit);
          setPictureEdit(null);
        } catch (caught) {
          console.error(`Failed to save the ${sending.picture}:`, caught);
          pictureError = caught ?? new Error("picture failed");
        }
      }
      // Only for what the user record carries, before the toast so the page has
      // changed by the time it says so.
      if (nameChanged || sending) await refreshUser();
      setSaves((count) => count + 1);
      if (pictureError && picture) {
        toast({
          title: `Saved, but your ${PICTURE_LABEL[picture]} did not`,
          description: getApiErrorMessage(
            pictureError,
            "Save again to retry it.",
          ),
          variant: "destructive",
        });
      } else {
        toast({ title: "Saved", description: savedMessage });
        // Not after a failed picture: closing a dialog would drop the pending pick.
        onSaved?.();
      }
    } catch (err) {
      setError(getApiErrorMessage(err, "Failed to save. Please try again."));
    }
  };

  const signInEmail = user?.email;
  // Watched so the link goes away once the box already holds the sign-in address,
  // whether it was stored that way, typed, or filled by the link itself.
  const typedEmail = useWatch({ control: form.control, name: "email" });
  const emailIsSignIn =
    !!signInEmail &&
    typeof typedEmail === "string" &&
    typedEmail.trim().toLowerCase() === signInEmail.toLowerCase();
  // The cast narrows the form to the members it shares with the import review's,
  // which is all a member field reads; `name` is this form's alone.
  const control = form.control as unknown as Control<CheckinDetailsFormValues>;

  return (
    <Form {...form}>
      {/* `dialogFormSubmit` unconditionally, as `UserFieldsForm` has it: most hosts
          are dialogs, and React bubbles submit through its own tree. */}
      <form
        onSubmit={dialogFormSubmit(form.handleSubmit(onSubmit))}
        className="flex flex-col flex-1"
      >
        <div className="space-y-4 flex-1">
          {picture && (
            <PictureField
              picture={picture}
              edit={pictureEdit}
              onChange={setPictureEdit}
              disabled={form.formState.isSubmitting}
            />
          )}
          {withName && (
            <FormField
              control={form.control}
              name="name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Full name</FormLabel>
                  <FormControl
                    render={
                      <Input type="text" autoComplete="name" {...field} />
                    }
                  />
                  <FormMessage />
                </FormItem>
              )}
            />
          )}
          {members.map((member) => (
            <div key={member} className="space-y-2">
              <CheckinMemberField
                member={member}
                control={control}
                // A card or dialog holding one list is headed by what the list is.
                hideLabel={members.length === 1}
              />
              {/* Filled here and saved with the rest, so choosing to hand out the
                  sign-in address costs one click and no request of its own. */}
              {member === "email" && signInEmail && !emailIsSignIn && (
                <Button
                  type="button"
                  variant="link"
                  size="sm"
                  className="h-auto p-0"
                  onClick={() =>
                    form.setValue("email", signInEmail, {
                      shouldDirty: true,
                      shouldValidate: true,
                    })
                  }
                >
                  Use my sign-in email
                </Button>
              )}
            </div>
          ))}
        </div>

        <FormApiError error={error} className="mt-4" />

        {children}
      </form>
    </Form>
  );
}