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. */}