src/lib/return-to.ts
Rama main · réplica de lectura del repositorio original.
// Where a form's or a record page's "back" link, a form's Cancel button, and
// (where it makes sense) a save or a delete should send someone.
//
// The dive form is reachable from at least six places - the Home page, the dive
// list, the header's create menu, and the "log a dive here" buttons on a trip or
// a dive site - and a dive from a trip, a site, the Home page and more, so a
// hardcoded `/dives` is wrong for most of them. Rather than each caller
// threading its own href through the page, the destination is read off the URL,
// which means it also survives a reload or a shared link.
//
// Deliberately not `router.back()`: a deep link or a refresh has no history
// entry to pop, and after a successful save "back" points at the form that was
// just submitted.
import { sanitizeRedirectPath } from "@/lib/auth-redirect";
export interface ReturnTarget {
href: string;
label: string;
}
// Section labels for the back link, in both their list and single-record forms:
// `/trips` is "Back to trips", `/trips/{uuid}` is "Back to trip". Every page inside
// the chrome needs one, since the header's create menu opens the dive form from
// any of them - `app/page-frames.render.test.tsx` derives that set and checks it.
const SECTIONS: Record<string, { index: string; item: string }> = {
home: { index: "home", item: "home" },
dives: { index: "dives", item: "dive" },
trips: { index: "trips", item: "trip" },
sites: { index: "dive sites", item: "dive site" },
gear: { index: "gear", item: "gear" },
certifications: { index: "certifications", item: "certification" },
courses: { index: "courses", item: "course" },
contacts: { index: "contacts", item: "contact" },
people: { index: "people", item: "person" },
species: { index: "marine life", item: "species" },
checkin: { index: "check-in", item: "check-in" },
import: { index: "import", item: "import" },
settings: { index: "settings", item: "settings" },
admin: { index: "admin", item: "admin" },
support: { index: "support", item: "support" },
privacy: { index: "privacy policy", item: "privacy policy" },
terms: { index: "terms of service", item: "terms of service" },
developers: { index: "API reference", item: "API reference" },
};
export function labelForPath(path: string): string {
const segments = path.split(/[?#]/)[0].split("/").filter(Boolean);
if (segments.join("/") === "sites/proposals") return "Back to your proposals";
const section = SECTIONS[segments[0]];
if (!section) return "Back";
return `Back to ${segments.length > 1 ? section.item : section.index}`;
}
// A form must never send someone back to a form. The header's create menu is
// present on the dive form itself, so without this a "New dive" opened from
// `/dives/new` would hand that same path back to Cancel.
export function isFormPath(path: string): boolean {
const route = path.split(/[?#]/)[0].replace(/\/$/, "");
return route === "/dives/new" || route.endsWith("/edit");
}
// Past this, a `from` keeps only its path. Every record followed from another
// nests the page before it, so a long enough walk would otherwise grow the URL
// without bound; this is a dozen or so records deep.
const FROM_MAX_LENGTH = 1000;
// `href` with a `?from=` naming the page it is followed from, so the page it
// opens can send the diver back there: a dive opened from a trip says "Back to
// trip". `from` is that page's path *and* query, so a trip opened from the
// Home page still says "Back to home" once the diver comes back to it from
// one of its dives.
//
// Nothing is added where the opened page's back link already points - its
// parent path, so a dive followed from `/dives` or an edit from its own dive
// keeps a clean URL - nor from a form, which nothing should return to. A link
// to the very page it is followed from is that page as it stands, way back and
// all, rather than a page that leads back to itself.
export function withReturnTo(href: string, from: string | null): string {
if (!from || isFormPath(from)) return href;
const path = href.split(/[?#]/)[0];
if (from.split(/[?#]/)[0] === path) return from;
if (from === path.slice(0, path.lastIndexOf("/"))) return href;
const kept = from.length > FROM_MAX_LENGTH ? from.split(/[?#]/)[0] : from;
const separator = href.includes("?") ? "&" : "?";
return `${href}${separator}from=${encodeURIComponent(kept)}`;
}
export interface ReturnToParams {
/** An explicit destination, set by whoever linked to the form. */
from?: string | null;
/** The form's own context params, used when there's no explicit `from`. */
trip_uuid?: string | null;
dive_site_uuid?: string | null;
course_uuid?: string | null;
}
// Resolution order, most to least specific:
// 1. `?from=` - the linking page said exactly where to go back to.
// 2. The context the form was opened with. "Log a dive for this trip" already
// passes `?trip_uuid=`, so that entry point needs no extra wiring to send
// the diver back to the trip they were looking at.
// 3. The caller's own fallback (the dive list, or the dive being edited).
//
// `from` goes through the same `sanitizeRedirectPath` guard as the post-sign-in
// redirect: only same-origin, path-relative destinations are honoured, so a
// hand-crafted `?from=//evil.example` can't turn a Cancel button into an open
// redirect.
export function resolveReturnTarget(
params: ReturnToParams,
fallback: ReturnTarget,
): ReturnTarget {
const from = sanitizeRedirectPath(params.from);
if (from && !isFormPath(from)) {
return { href: from, label: labelForPath(from) };
}
if (params.trip_uuid) {
return {
href: `/trips/${params.trip_uuid}`,
label: "Back to trip",
};
}
if (params.dive_site_uuid) {
return {
href: `/sites/${params.dive_site_uuid}`,
label: "Back to dive site",
};
}
if (params.course_uuid) {
return {
href: `/courses/${params.course_uuid}`,
label: "Back to course",
};
}
return fallback;
}