mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Merge b8f1df3b7a into 7ed89a2a9c
This commit is contained in:
commit
834a292848
@ -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" />
|
||||
|
||||
62
apps/dashboard/src/lib/hosted-back-url.test.ts
Normal file
62
apps/dashboard/src/lib/hosted-back-url.test.ts
Normal 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("/");
|
||||
});
|
||||
});
|
||||
30
apps/dashboard/src/lib/hosted-back-url.ts
Normal file
30
apps/dashboard/src/lib/hosted-back-url.ts
Normal 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 ?? "/";
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user