This commit is contained in:
Armaan Jain 2026-07-19 07:20:24 -07:00 committed by GitHub
commit 834a292848
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 186 additions and 10 deletions

View File

@ -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 (
<a
href={url}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:transition-none hover:text-foreground"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</a>
);
}
type ProductData = {
product?: Omit<yup.InferType<typeof inlineProductSchema>, "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<string | null>(null);
const [quantityInput, setQuantityInput] = useState<string>("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 (
<div data-hexclave-purchase-page className="relative flex min-h-screen items-center justify-center bg-white px-6 dark:bg-zinc-950">
<div className="w-full max-w-md text-center">
<div className="mb-4 text-left">
<BackButton url={backUrl} />
</div>
<DesignCard glassmorphic contentClassName="flex flex-col items-center gap-4 p-8">
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/10">
<XCircleIcon className="size-6 text-destructive" weight="fill" />
@ -230,7 +254,10 @@ export default function PageClient({ code }: { code: string }) {
<div className="relative flex min-h-screen w-full flex-col lg:flex-row">
{/* Left Panel: Product & Pricing Selection */}
<div className="flex flex-1 flex-col border-b border-border/40 bg-white dark:bg-zinc-950 lg:w-1/2 lg:border-b-0 lg:border-r">
<div className="mx-auto w-full max-w-md px-6 pb-12 pt-16 lg:pt-20">
<div className="mx-auto w-full max-w-md px-6 pb-12 pt-10 lg:pt-12">
<div className="mb-6">
<BackButton url={backUrl} />
</div>
{loading ? (
<div className="space-y-5">
<Skeleton className="size-12 rounded-full" />

View File

@ -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("/");
});
});

View File

@ -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 ?? "/";
}

View File

@ -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: {
<SidebarLayout
items={sidebarItems as any}
title="Account Settings"
backUrl={backUrl}
/>
</HostedFullPage>
);

View File

@ -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 (
<>
<div className={cn("hidden sm:flex flex-1 min-h-full", props.className)}>
<DesktopLayout items={props.items} title={props.title} selectedIndex={selectedIndex} />
<DesktopLayout items={props.items} title={props.title} selectedIndex={selectedIndex} backUrl={props.backUrl} />
</div>
<div className={cn("sm:hidden flex-1 min-h-full", props.className)}>
<MobileLayout items={props.items} title={props.title} selectedIndex={selectedIndex} />
<MobileLayout items={props.items} title={props.title} selectedIndex={selectedIndex} backUrl={props.backUrl} />
</div>
</>
);
@ -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 (
<a
href={url}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:transition-none hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
{label}
</a>
);
}
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. */}
<aside className="sticky top-0 h-screen flex flex-col items-stretch gap-1 overflow-y-auto shrink-0 w-[260px] border-r border-black/[0.06] dark:border-white/[0.06] bg-zinc-100/70 dark:bg-zinc-900/45 px-4 py-6">
{props.backUrl && (
<div className="ml-3 mb-3">
<BackButton url={props.backUrl} />
</div>
)}
{props.title && (
<div className="ml-3 mb-4">
<h2 className="font-semibold text-xl tracking-tight text-foreground">
@ -105,12 +122,17 @@ function DesktopLayout(props: { items: SidebarItem[], title?: ReactNode, selecte
);
}
function MobileLayout(props: { items: SidebarItem[], title?: ReactNode, selectedIndex: number }) {
function MobileLayout(props: { items: SidebarItem[], title?: ReactNode, selectedIndex: number, backUrl?: string | null }) {
const selectedItem = props.items[props.selectedIndex];
if (props.selectedIndex === -1) {
return (
<div className="flex flex-col gap-2 p-2">
{props.backUrl && (
<div className="ml-2 mb-1">
<BackButton url={props.backUrl} />
</div>
)}
{props.title && (
<div className="mb-2 ml-2">
<h2 className="text-lg font-semibold text-foreground">{props.title}</h2>
@ -124,6 +146,11 @@ function MobileLayout(props: { items: SidebarItem[], title?: ReactNode, selected
return (
<div className="flex flex-col gap-4 p-4">
{props.backUrl && (
<div className="-mb-2">
<BackButton url={props.backUrl} label="Back to site" />
</div>
)}
<Button
variant="ghost"
size="sm"