diff --git a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx index 3c3342e62..85aa0674e 100644 --- a/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/purchase/[code]/page-client.tsx @@ -9,7 +9,8 @@ import { DesignAlert } from "@/components/design-components/alert"; import { DesignCard } from "@/components/design-components/card"; import { Skeleton, Typography } from "@/components/ui"; import { getPublicEnvVar } from "@/lib/env"; -import { XCircleIcon } from "@phosphor-icons/react"; +import { useHostedBackUrl } from "@/lib/hosted-back-url"; +import { ArrowLeftIcon, XCircleIcon } from "@phosphor-icons/react"; import { inlineProductSchema } from "@hexclave/shared/dist/schema-fields"; import { throwErr } from "@hexclave/shared/dist/utils/errors"; import { typedEntries } from "@hexclave/shared/dist/utils/objects"; @@ -18,6 +19,24 @@ import { useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import * as yup from "yup"; +function isValidReturnUrl(url: string): boolean { + if (!URL.canParse(url)) return false; + const parsed = new URL(url); + return parsed.protocol === "https:" || parsed.protocol === "http:"; +} + +function BackButton({ url }: { url: string }) { + return ( + + + Back + + ); +} + type ProductData = { product?: Omit, "included_items" | "server_only"> & { stackable: boolean }, stripe_account_id: string | null, @@ -75,7 +94,9 @@ export default function PageClient({ code }: { code: string }) { const [selectedPriceId, setSelectedPriceId] = useState(null); const [quantityInput, setQuantityInput] = useState("1"); const searchParams = useSearchParams(); - const returnUrl = searchParams.get("return_url"); + const rawReturnUrl = searchParams.get("return_url"); + const returnUrl = rawReturnUrl && isValidReturnUrl(rawReturnUrl) ? rawReturnUrl : null; + const backUrl = useHostedBackUrl(returnUrl); const quantityNumber = useMemo((): number => { const n = parseInt(quantityInput, 10); @@ -207,6 +228,9 @@ export default function PageClient({ code }: { code: string }) { return (
+
+ +
@@ -230,7 +254,10 @@ export default function PageClient({ code }: { code: string }) {
{/* Left Panel: Product & Pricing Selection */}
-
+
+
+ +
{loading ? (
diff --git a/apps/dashboard/src/lib/hosted-back-url.test.ts b/apps/dashboard/src/lib/hosted-back-url.test.ts new file mode 100644 index 000000000..2b07f73d1 --- /dev/null +++ b/apps/dashboard/src/lib/hosted-back-url.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment jsdom + +import { renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useHostedBackUrl } from "./hosted-back-url"; + +describe("useHostedBackUrl", () => { + afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(document, "referrer", { + configurable: true, + value: "", + }); + }); + + it("prefers an explicit back URL when provided", () => { + Object.defineProperty(document, "referrer", { + configurable: true, + value: "https://example.com/pricing", + }); + + const { result } = renderHook(() => useHostedBackUrl("https://app.example.com/billing")); + + expect(result.current).toBe("https://app.example.com/billing"); + }); + + it("uses an external referrer when no explicit back URL is provided", () => { + Object.defineProperty(document, "referrer", { + configurable: true, + value: "https://example.com/pricing", + }); + Object.defineProperty(window, "location", { + configurable: true, + value: { + ...window.location, + origin: "https://checkout.example.com", + }, + }); + + const { result } = renderHook(() => useHostedBackUrl(null)); + + expect(result.current).toBe("https://example.com/pricing"); + }); + + it("falls back to the hosted app root when there is no explicit or external back URL", () => { + Object.defineProperty(document, "referrer", { + configurable: true, + value: "https://checkout.example.com/purchase/abc", + }); + Object.defineProperty(window, "location", { + configurable: true, + value: { + ...window.location, + origin: "https://checkout.example.com", + }, + }); + + const { result } = renderHook(() => useHostedBackUrl(undefined)); + + expect(result.current).toBe("/"); + }); +}); diff --git a/apps/dashboard/src/lib/hosted-back-url.ts b/apps/dashboard/src/lib/hosted-back-url.ts new file mode 100644 index 000000000..420df24fe --- /dev/null +++ b/apps/dashboard/src/lib/hosted-back-url.ts @@ -0,0 +1,30 @@ +import { useMemo, useSyncExternalStore } from "react"; + +function getReferrerSnapshot(): string { + if (typeof document === "undefined") return ""; + return document.referrer; +} + +function subscribeToReferrer() { + // document.referrer never changes after page load, so no-op subscription + return () => {}; +} + +function useExternalBackUrl(): string | null { + const referrer = useSyncExternalStore(subscribeToReferrer, getReferrerSnapshot, () => ""); + return useMemo(() => { + if (!referrer || !URL.canParse(referrer)) return null; + const parsed = new URL(referrer); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + if (parsed.origin === window.location.origin) return null; + return referrer; + }, [referrer]); +} + +export function useHostedBackUrl(explicitBackUrl?: string | null): string { + const externalBackUrl = useExternalBackUrl(); + if (explicitBackUrl) { + return explicitBackUrl; + } + return externalBackUrl ?? "/"; +} diff --git a/apps/hosted-components/src/hosted-components/account-settings/index.tsx b/apps/hosted-components/src/hosted-components/account-settings/index.tsx index 6a4ac5c1d..8fa19d86d 100644 --- a/apps/hosted-components/src/hosted-components/account-settings/index.tsx +++ b/apps/hosted-components/src/hosted-components/account-settings/index.tsx @@ -1,6 +1,6 @@ import { Skeleton, cn } from "~/components/ui"; import { Bell, Contact, CreditCard, Key, Monitor, PlusCircle, Settings, ShieldCheck } from "lucide-react"; -import React, { Suspense, useEffect, useMemo, useRef, useState } from "react"; +import React, { Suspense, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { Team, useStackApp, useUser } from "@hexclave/react"; import { HostedFullPage } from "./hosted-full-page"; import { SidebarLayout } from './sidebar-layout'; @@ -27,6 +27,34 @@ const Icon = ({ name }: { name: keyof typeof iconMap }) => { const emptyTeams: Team[] = []; +function getReferrerSnapshot(): string { + if (typeof document === "undefined") return ""; + return document.referrer; +} + +function subscribeToReferrer() { + // document.referrer never changes after page load, so no-op subscription + return () => {}; +} + +function useExternalBackUrl(): string | null { + const referrer = useSyncExternalStore(subscribeToReferrer, getReferrerSnapshot, () => ""); + return useMemo(() => { + if (!referrer || !URL.canParse(referrer)) return null; + const parsed = new URL(referrer); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null; + if (parsed.origin === window.location.origin) return null; + return referrer; + }, [referrer]); +} + +function useAccountSettingsBackUrl(): string { + const externalBackUrl = useExternalBackUrl(); + // Direct visits and same-origin navigations do not have a useful referrer, but the full-page + // account settings route still needs an obvious way back to the hosted app surface. + return externalBackUrl ?? "/"; +} + const EmailsAndAuthPage = React.lazy(async () => ({ default: (await import("./email-and-auth/email-and-auth-page")).EmailsAndAuthPage, })); @@ -86,6 +114,7 @@ export function HostedAccountSettings(props: { }, }>, }) { + const backUrl = useAccountSettingsBackUrl(); const userFromHook = useUser({ or: props.mockUser ? 'return-null' : 'redirect' }); const stackApp = useStackApp(); const projectFromHook = stackApp.useProject(); @@ -267,6 +296,7 @@ export function HostedAccountSettings(props: { ); diff --git a/apps/hosted-components/src/hosted-components/account-settings/sidebar-layout.tsx b/apps/hosted-components/src/hosted-components/account-settings/sidebar-layout.tsx index 05516a8a6..e2c80dc57 100644 --- a/apps/hosted-components/src/hosted-components/account-settings/sidebar-layout.tsx +++ b/apps/hosted-components/src/hosted-components/account-settings/sidebar-layout.tsx @@ -1,6 +1,6 @@ import { Button, cn } from "~/components/ui"; import { useHash } from '@hexclave/shared/dist/hooks/use-hash'; -import { XIcon } from 'lucide-react'; +import { ArrowLeft, XIcon } from 'lucide-react'; import React, { ReactNode } from 'react'; export type SidebarItem = { @@ -13,16 +13,16 @@ export type SidebarItem = { contentTitle?: React.ReactNode, } -export function SidebarLayout(props: { items: SidebarItem[], title?: ReactNode, className?: string }) { +export function SidebarLayout(props: { items: SidebarItem[], title?: ReactNode, className?: string, backUrl?: string | null }) { const hash = useHash(); const selectedIndex = props.items.findIndex(item => item.id && (item.id === hash)); return ( <>
- +
- +
); @@ -65,7 +65,19 @@ function Items(props: { items: SidebarItem[], selectedIndex: number }) { )); } -function DesktopLayout(props: { items: SidebarItem[], title?: ReactNode, selectedIndex: number }) { +function BackButton({ url, label = "Back" }: { url: string, label?: string }) { + return ( + + + {label} + + ); +} + +function DesktopLayout(props: { items: SidebarItem[], title?: ReactNode, selectedIndex: number, backUrl?: string | null }) { const selectedItem = props.items[props.selectedIndex === -1 ? 0 : props.selectedIndex]; return ( @@ -74,6 +86,11 @@ function DesktopLayout(props: { items: SidebarItem[], title?: ReactNode, selecte pinned while the page scrolls with the document. Slightly darker than the page in light mode, slightly lighter in dark mode, so it reads as a distinct surface. */}