Add animated slide-in sheet for plan editor

- Remove default sheet display on page load
- Add Motion animations for sheet slide-in/out from right
- Animate main content and components width when sheet opens/closes
- Add click outside and Escape key to close sheet
- Synchronize all animations with spring physics

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John Yeo
2025-10-20 19:15:49 +01:00
parent 95b49a2b64
commit b09645f62d
16 changed files with 272 additions and 79 deletions

View File

@@ -17,8 +17,9 @@
"linter": {
"enabled": true,
"rules": {
"correctness": {
"useExhaustiveDependencies": "off"
"a11y": {
"noStaticElementInteractions": "off",
"useKeyWithClickEvents": "off"
},
"recommended": true,
"complexity": {
@@ -29,7 +30,8 @@
"noUnknownAtRules": "off"
},
"correctness": {
"useParseIntRadix": "off"
"useParseIntRadix": "off",
"useExhaustiveDependencies": "off"
}
}
},

View File

@@ -11,10 +11,15 @@ import { IconButton } from "./IconButton";
interface CopyButtonProps extends IconButtonProps {
children?: React.ReactNode;
side?: "top" | "bottom" | "left" | "right";
text: string;
}
export const CopyButton = ({ text, ...props }: CopyButtonProps) => {
export const CopyButton = ({
text,
side = "right",
...props
}: CopyButtonProps) => {
const [copied, setCopied] = useState(false);
useEffect(() => {
@@ -47,7 +52,7 @@ export const CopyButton = ({ text, ...props }: CopyButtonProps) => {
</IconButton>
</TooltipTrigger>
<TooltipContent
side="right"
side={side}
sideOffset={8}
className="bg-white text-body p-2 py-1 border rounded-lg shadow-sm"
>

View File

@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { create } from "zustand";
// Sheet types that can be displayed
@@ -52,3 +53,49 @@ export const useIsEditingFeature = () =>
useSheetStore((s) => s.type === "edit-feature");
export const useIsCreatingFeature = () =>
useSheetStore((s) => s.type === "new-feature" || s.itemId === "new");
/**
* Hook to handle Escape key to close sheet and unfocus active elements
* Only closes sheet if no dialog is currently open
*/
export const useSheetEscapeHandler = () => {
const sheetType = useSheetStore((s) => s.type);
const closeSheet = useSheetStore((s) => s.closeSheet);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape" && sheetType) {
// Check if any dialog is open (Radix UI, native dialog, etc.)
const isDialogOpen =
document.querySelector('[role="dialog"]') ||
document.querySelector('[data-state="open"][role="alertdialog"]') ||
document.querySelector("dialog[open]");
// Only close sheet if no dialog is open
if (!isDialogOpen) {
closeSheet();
// Unfocus any active element
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [sheetType, closeSheet]);
};
/**
* Hook to close sheet when component unmounts (e.g., navigating away)
*/
export const useSheetCleanup = () => {
const closeSheet = useSheetStore((s) => s.closeSheet);
useEffect(() => {
return () => {
closeSheet();
};
}, [closeSheet]);
};

View File

@@ -1,8 +1,6 @@
import { productV2ToBasePrice } from "@autumn/shared";
import { CrosshairSimpleIcon } from "@phosphor-icons/react";
import { PricingTableContainer } from "@/components/autumn/PricingTableContainer";
import { PlanTypeBadges } from "@/components/v2/badges/PlanTypeBadges";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { Card, CardContent, CardHeader } from "@/components/v2/cards/Card";
import { Separator } from "@/components/v2/separator";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
@@ -10,7 +8,7 @@ import { useFeatureStore } from "@/hooks/stores/useFeatureStore";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useIsEditingPlan, useSheetStore } from "@/hooks/stores/useSheetStore";
import { cn } from "@/lib/utils";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { BasePriceDisplay } from "../products/plan/components/plan-card/BasePriceDisplay";
import { PlanCardToolbar } from "../products/plan/components/plan-card/PlanCardToolbar";
import { PlanFeatureList } from "../products/plan/components/plan-card/PlanFeatureList";
import { DummyFeatureRow } from "./components/DummyFeatureRow";
@@ -134,24 +132,25 @@ export const OnboardingPreview = ({
</div>
{showPricing && (
<IconButton
variant="secondary"
icon={<CrosshairSimpleIcon />}
className="mt-2 !opacity-100 pointer-events-none"
onClick={handleEdit}
disabled={true}
>
{basePrice?.amount ? (
<span className="text-sm font-medium text-t2">
${basePrice.amount}/
{keyToTitle(basePrice.interval ?? "once", {
exclusionMap: { one_off: "once" },
}).toLowerCase()}
</span>
) : (
<span className="text-t4 text-sm">No price set</span>
)}
</IconButton>
<BasePriceDisplay />
// <IconButton
// variant="secondary"
// icon={<CrosshairSimpleIcon />}
// className="mt-2 !opacity-100 pointer-events-none"
// onClick={handleEdit}
// disabled={true}
// >
// {basePrice?.amount ? (
// <span className="text-sm font-medium text-t2">
// ${basePrice.amount}/
// {keyToTitle(basePrice.interval ?? "once", {
// exclusionMap: { one_off: "once" },
// }).toLowerCase()}
// </span>
// ) : (
// <span className="text-t4 text-sm">No price set</span>
// )}
// </IconButton>
)}
</CardHeader>
{showDummyFeature && feature && (

View File

@@ -1,17 +1,23 @@
import { AxiosError } from "axios";
import { useEffect, useState } from "react";
import { motion } from "motion/react";
import { useState } from "react";
import { useParams } from "react-router";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductSync } from "@/hooks/stores/useProductSync";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import {
useSheetCleanup,
useSheetEscapeHandler,
useSheetStore,
} from "@/hooks/stores/useSheetStore";
import ErrorScreen from "@/views/general/ErrorScreen";
import LoadingScreen from "@/views/general/LoadingScreen";
import { useProductQuery } from "../product/hooks/useProductQuery";
import { ProductContext } from "../product/ProductContext";
import { EditPlanHeader } from "./components/EditPlanHeader";
import { ManagePlan } from "./components/ManagePlan";
import PlanCard from "./components/plan-card/PlanCard";
import { SaveChangesBar } from "./components/SaveChangesBar";
import { ProductSheets } from "./ProductSheets";
import { SHEET_ANIMATION } from "./planAnimations";
import ConfirmNewVersionDialog from "./versioning/ConfirmNewVersionDialog";
export default function PlanEditorView() {
@@ -31,10 +37,14 @@ export default function PlanEditorView() {
const [showNewVersionDialog, setShowNewVersionDialog] = useState(false);
const setSheet = useSheetStore((s) => s.setSheet);
const closeSheet = useSheetStore((s) => s.closeSheet);
const sheetType = useSheetStore((s) => s.type);
useEffect(() => {
setSheet({ type: "edit-plan" });
}, [setSheet]);
// Handle Escape key to close sheet and unfocus
useSheetEscapeHandler();
// Close sheet when navigating away from this view
useSheetCleanup();
if (featuresLoading || productLoading) return <LoadingScreen />;
@@ -66,12 +76,28 @@ export default function PlanEditorView() {
setSheet({ type: "edit-plan" });
}}
/>
<div className="flex w-full h-full overflow-y-auto bg-gray-medium">
<div className="flex flex-col justify-between h-full w-full overflow-x-hidden relative">
<div className="flex w-full h-full overflow-hidden bg-gray-medium relative">
<motion.div
className="flex flex-col justify-between h-full overflow-x-hidden overflow-y-auto absolute inset-0"
animate={{
width: sheetType ? "calc(100% - 28rem)" : "100%",
}}
transition={SHEET_ANIMATION}
>
<EditPlanHeader />
<ManagePlan />
{/* <ManagePlan /> */}
<div
className="flex flex-col w-full h-full bg-gray-medium items-center justify-start pt-20 px-10"
// onClick={() => {
// if (sheetType) {
// closeSheet();
// }
// }}
>
<PlanCard />
</div>
<SaveChangesBar />
</div>
</motion.div>
<ProductSheets />
</div>

View File

@@ -1,14 +1,16 @@
import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared";
import { AnimatePresence, motion } from "motion/react";
import { SheetContainer } from "@/components/v2/sheets/InlineSheet";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { getItemId } from "@/utils/product/productItemUtils";
import { ProductItemContext } from "../product/product-item/ProductItemContext";
import { EditPlanFeatureSheet } from "./components/edit-plan-feature/EditPlanFeatureSheet";
import { EditPlanSheet } from "./components/EditPlanSheet";
import { EditPlanFeatureSheet } from "./components/edit-plan-feature/EditPlanFeatureSheet";
import { NewFeatureSheet } from "./components/new-feature/NewFeatureSheet";
import { SelectFeatureSheet } from "./components/SelectFeatureSheet";
import { SHEET_ANIMATION } from "./planAnimations";
export const ProductSheets = () => {
const product = useProductStore((s) => s.product);
@@ -73,8 +75,20 @@ export const ProductSheets = () => {
};
return (
<SheetContainer className="w-full min-w-xs max-w-md bg-card z-50 border-l shadow-sm h-full">
{renderSheet()}
</SheetContainer>
<AnimatePresence mode="wait">
{sheetType && (
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={SHEET_ANIMATION}
className="h-full w-[28rem] absolute right-0 top-0 bottom-0"
>
<SheetContainer className="w-full bg-card z-50 border-l shadow-sm h-full">
{renderSheet()}
</SheetContainer>
</motion.div>
)}
</AnimatePresence>
);
};

View File

@@ -6,6 +6,7 @@ import { Badge } from "@/components/v2/badges/Badge";
import { IconBadge } from "@/components/v2/badges/IconBadge";
import V2Breadcrumb from "@/components/v2/breadcrumb";
import { Button } from "@/components/v2/buttons/Button";
import { CopyButton } from "@/components/v2/buttons/CopyButton.tsx";
import {
Select,
SelectContent,
@@ -13,6 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
import { isOneOffProduct } from "@/utils/product/priceUtils";
@@ -24,6 +26,7 @@ import {
useProductQueryState,
} from "../../product/hooks/useProductQuery";
import { ConfirmMigrationDialog } from "./ConfirmMigrationDialog";
import { PlanToolbar } from "./PlanToolbar.tsx";
export const EditPlanHeader = () => {
const { product, numVersions } = useProductQuery();
@@ -31,6 +34,7 @@ export const EditPlanHeader = () => {
const { refetch: refetchMigrations } = useMigrationsQuery();
const { queryStates, setQueryStates } = useProductQueryState();
const axiosInstance = useAxiosInstance();
const sheetType = useSheetStore((s) => s.type);
const [confirmMigrateOpen, setConfirmMigrateOpen] = useState(false);
@@ -105,7 +109,7 @@ export const EditPlanHeader = () => {
startMigration={migrateCustomers}
version={version}
/>
<div className="flex flex-col gap-2 p-4 pb-3 w-full bg-card border-none shadow-none">
<div className="flex flex-col gap-2 p-4 pb-3 bg-card border-none shadow-none w-full">
<V2Breadcrumb
className="p-0"
items={[
@@ -131,13 +135,21 @@ export const EditPlanHeader = () => {
</div>
<div className="flex flex-row justify-between items-center">
<div className="flex flex-row gap-2">
{/* {badgeType && <Badge variant="muted">{badgeType}</Badge>} */}
{product?.id && (
<CopyButton
side="bottom"
text={product?.id ? product?.id : ""}
className="text-xs"
size="sm"
/>
)}
{product.is_default && <Badge variant="muted">Default</Badge>}
{product.is_add_on && <Badge variant="muted">Add-on</Badge>}
<IconBadge variant="muted" icon={<UserIcon />}>
{counts?.active || 0}
</IconBadge>
<PlanTypeBadge product={product} />
<PlanToolbar />
</div>
<div className="flex flex-row gap-2 items-center">

View File

@@ -0,0 +1,50 @@
import { EllipsisVerticalIcon } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { pushPage } from "@/utils/genUtils";
import { DeletePlanDialog } from "./DeletePlanDialog";
export const PlanToolbar = () => {
const [deleteOpen, setDeleteOpen] = useState(false);
const navigate = useNavigate();
return (
<>
<DeletePlanDialog
open={deleteOpen}
setOpen={setDeleteOpen}
onDeleteSuccess={async () => {
pushPage({
navigate,
path: "/products",
queryParams: {
tab: "products",
},
preserveParams: true,
});
}}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
icon={<EllipsisVerticalIcon />}
size="sm"
variant="muted"
iconOrientation="center"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => setDeleteOpen(true)}>
Delete Plan
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
};

View File

@@ -14,9 +14,9 @@ export const AdditionalOptions = ({
<SheetSection title="Additional Options" withSeparator={withSeparator}>
<div className="space-y-4">
<AreaCheckbox
title="Enable By Default"
title="Enable by Default"
description="This product will be enabled by default for all new users,
typically used for your free product"
typically used for your free plan"
checked={product.is_default}
disabled={product.is_add_on}
onCheckedChange={(checked) =>

View File

@@ -114,8 +114,7 @@ export const BasePriceSection = ({
description={
<span>
A fixed price to charge for the plan. Uncheck this section if the plan
is <span className="text-primary font-bold">free</span> or{" "}
<span className="text-primary font-bold">a variable price</span>.
is free or only has usage-based prices.
</span>
}
>

View File

@@ -60,7 +60,7 @@ export function EditPlanFeatureSheet({
)}
{feature?.type === FeatureType.Boolean && (
<div className="p-4 flex flex-col gap-2 min-h-full items-center justify-center">
<div className="p-4 flex flex-col gap-2 h-full items-center justify-center">
<h1 className="text-sub">Nothing to do here...</h1>
<p className="text-body-secondary max-w-[75%]">
Boolean features are simply included in the

View File

@@ -0,0 +1,47 @@
import {
formatAmount,
mapToProductV3,
type Organization,
} from "@autumn/shared";
import { useOrg } from "@/hooks/common/useOrg";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { cn } from "@/lib/utils";
import { notNullish } from "@/utils/genUtils";
import { useOnboardingStore } from "@/views/onboarding3/store/useOnboardingStore";
export const BasePriceDisplay = () => {
const product = useProductStore((s) => s.product);
const productV3 = mapToProductV3({ product });
const isOnboarding = useOnboardingStore((s) => s.isOnboarding);
const { org } = useOrg();
const formattedAmount = formatAmount({
org: org as unknown as Organization,
amount: productV3.price?.amount ?? 0,
amountFormatOptions: {
style: "currency",
currency: org?.default_currency || "USD",
currencyDisplay: "narrowSymbol",
},
});
const secondaryText = productV3.price?.interval
? `per ${productV3.price.interval}`
: "once";
const priceExists = notNullish(productV3.price);
return (
<div className={cn(isOnboarding && "mt-1")}>
{priceExists ? (
<span className="text-body-secondary">
<span className="text-main-sec">{formattedAmount}</span>{" "}
{secondaryText}
</span>
) : (
<span className="text-t4 text-body-secondary inline-block mt-[4.5px]">
No base price
</span>
)}
</div>
);
};

View File

@@ -1,6 +1,7 @@
import { useHotkeys } from "react-hotkeys-hook";
import { Card, CardContent } from "@/components/v2/cards/Card";
import { Separator } from "@/components/v2/separator";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useFeatureNavigation } from "../../hooks/useFeatureNavigation";
import { PlanCardHeader } from "./PlanCardHeader";
import { PlanFeatureList } from "./PlanFeatureList";
@@ -8,13 +9,17 @@ import { PlanFeatureList } from "./PlanFeatureList";
export default function PlanCard() {
// Initialize feature navigation (registers hotkeys internally)
useFeatureNavigation();
const sheetType = useSheetStore((s) => s.type);
useHotkeys("ctrl+s", () => {
console.log("Save");
});
return (
<Card className={`min-w-sm max-w-xl mx-4 bg-card w-[80%]`}>
<Card
className="min-w-sm max-w-xl mx-4 bg-card w-full"
onClick={(e) => e.stopPropagation()}
>
<PlanCardHeader />
<div className="px-4">

View File

@@ -1,18 +1,16 @@
import { mapToProductV3 } from "@autumn/shared";
import { CrosshairSimpleIcon } from "@phosphor-icons/react";
import { useNavigate } from "react-router";
import { PlanTypeBadges } from "@/components/v2/badges/PlanTypeBadges";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { CardHeader } from "@/components/v2/cards/Card";
import { useOrg } from "@/hooks/common/useOrg";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useIsEditingPlan, useSheetStore } from "@/hooks/stores/useSheetStore";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { BasePriceDisplay } from "./BasePriceDisplay";
import { PlanCardToolbar } from "./PlanCardToolbar";
const MAX_PLAN_NAME_LENGTH = 20;
export const PlanCardHeader = () => {
const navigate = useNavigate();
const { org } = useOrg();
const product = useProductStore((s) => s.product);
const setSheet = useSheetStore((s) => s.setSheet);
const isPlanBeingEdited = useIsEditingPlan();
@@ -47,7 +45,8 @@ export const PlanCardHeader = () => {
</span>
)}
<IconButton
<BasePriceDisplay />
{/* <IconButton
variant="secondary"
icon={<CrosshairSimpleIcon />}
onClick={() => {
@@ -56,17 +55,8 @@ export const PlanCardHeader = () => {
disabled={true}
className="mt-2 !opacity-100 pointer-events-none"
>
{productV3.price?.amount ? (
<span className="text-sm font-medium text-t2">
${productV3.price.amount}/
{keyToTitle(productV3.price.interval ?? "once", {
exclusionMap: { one_off: "once" },
}).toLowerCase()}
</span>
) : (
<span className="text-t4 text-sm">No price set</span>
)}
</IconButton>
{renderBasePrice()}
</IconButton> */}
</CardHeader>
);
};

View File

@@ -1,8 +1,6 @@
import { PencilSimpleIcon, TrashIcon } from "@phosphor-icons/react";
import { PencilSimpleIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { Button } from "@/components/v2/buttons/Button";
import { CopyButton } from "@/components/v2/buttons/CopyButton";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useIsEditingPlan } from "@/hooks/stores/useSheetStore";
@@ -48,24 +46,19 @@ export const PlanCardToolbar = ({
}}
/>
<div className="flex flex-row items-center gap-1">
{product?.id && (
<CopyButton
text={product?.id ? product?.id : ""}
className="text-xs"
size="sm"
/>
)}
<IconButton
icon={<PencilSimpleIcon />}
onClick={onEdit}
aria-label="Edit plan"
variant="muted"
disabled={editDisabled}
iconOrientation="center"
size="sm"
className={cn(isEditingPlan && "btn-secondary-active !opacity-100 ")}
/>
>
Edit Details
</IconButton>
{product?.archived ? (
{/* {product?.archived ? (
<Button variant="muted" onClick={() => setDeleteOpen(true)} size="sm">
Archived
</Button>
@@ -80,7 +73,7 @@ export const PlanCardToolbar = ({
title={deleteDisabled && deleteTooltip ? deleteTooltip : undefined}
className={cn(deleteDisabled && "opacity-50 cursor-not-allowed")}
/>
)}
)} */}
</div>
</>
);

View File

@@ -0,0 +1,4 @@
export const SHEET_ANIMATION = {
duration: 0.5,
ease: [0.32, 0.72, 0, 1] as const,
} as const;