From b09645f62ddec0a59424a24648d2d0859a8b095c Mon Sep 17 00:00:00 2001 From: John Yeo Date: Mon, 20 Oct 2025 19:15:49 +0100 Subject: [PATCH] Add animated slide-in sheet for plan editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- vite/biome.json | 8 +-- vite/src/components/v2/buttons/CopyButton.tsx | 9 +++- vite/src/hooks/stores/useSheetStore.ts | 47 +++++++++++++++++ .../views/onboarding3/OnboardingPreview.tsx | 41 ++++++++------- .../views/products/plan/PlanEditorView.tsx | 46 +++++++++++++---- .../src/views/products/plan/ProductSheets.tsx | 22 ++++++-- .../plan/components/EditPlanHeader.tsx | 16 +++++- .../products/plan/components/PlanToolbar.tsx | 50 +++++++++++++++++++ .../edit-plan-details/AdditionalOptions.tsx | 4 +- .../edit-plan-details/BasePriceSection.tsx | 3 +- .../EditPlanFeatureSheet.tsx | 2 +- .../components/plan-card/BasePriceDisplay.tsx | 47 +++++++++++++++++ .../plan/components/plan-card/PlanCard.tsx | 7 ++- .../components/plan-card/PlanCardHeader.tsx | 24 +++------ .../components/plan-card/PlanCardToolbar.tsx | 21 +++----- .../src/views/products/plan/planAnimations.ts | 4 ++ 16 files changed, 272 insertions(+), 79 deletions(-) create mode 100644 vite/src/views/products/plan/components/PlanToolbar.tsx create mode 100644 vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx create mode 100644 vite/src/views/products/plan/planAnimations.ts diff --git a/vite/biome.json b/vite/biome.json index 50c0de90c..965d4df92 100644 --- a/vite/biome.json +++ b/vite/biome.json @@ -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" } } }, diff --git a/vite/src/components/v2/buttons/CopyButton.tsx b/vite/src/components/v2/buttons/CopyButton.tsx index b927391a5..a86546e4b 100644 --- a/vite/src/components/v2/buttons/CopyButton.tsx +++ b/vite/src/components/v2/buttons/CopyButton.tsx @@ -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) => { diff --git a/vite/src/hooks/stores/useSheetStore.ts b/vite/src/hooks/stores/useSheetStore.ts index 06641ec95..f0cc5e067 100644 --- a/vite/src/hooks/stores/useSheetStore.ts +++ b/vite/src/hooks/stores/useSheetStore.ts @@ -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]); +}; diff --git a/vite/src/views/onboarding3/OnboardingPreview.tsx b/vite/src/views/onboarding3/OnboardingPreview.tsx index b92135d67..ad3e29286 100644 --- a/vite/src/views/onboarding3/OnboardingPreview.tsx +++ b/vite/src/views/onboarding3/OnboardingPreview.tsx @@ -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 = ({ {showPricing && ( - } - className="mt-2 !opacity-100 pointer-events-none" - onClick={handleEdit} - disabled={true} - > - {basePrice?.amount ? ( - - ${basePrice.amount}/ - {keyToTitle(basePrice.interval ?? "once", { - exclusionMap: { one_off: "once" }, - }).toLowerCase()} - - ) : ( - No price set - )} - + + // } + // className="mt-2 !opacity-100 pointer-events-none" + // onClick={handleEdit} + // disabled={true} + // > + // {basePrice?.amount ? ( + // + // ${basePrice.amount}/ + // {keyToTitle(basePrice.interval ?? "once", { + // exclusionMap: { one_off: "once" }, + // }).toLowerCase()} + // + // ) : ( + // No price set + // )} + // )} {showDummyFeature && feature && ( diff --git a/vite/src/views/products/plan/PlanEditorView.tsx b/vite/src/views/products/plan/PlanEditorView.tsx index d7d5ab1f1..48420a46d 100644 --- a/vite/src/views/products/plan/PlanEditorView.tsx +++ b/vite/src/views/products/plan/PlanEditorView.tsx @@ -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 ; @@ -66,12 +76,28 @@ export default function PlanEditorView() { setSheet({ type: "edit-plan" }); }} /> -
-
+
+ - + {/* */} +
{ + // if (sheetType) { + // closeSheet(); + // } + // }} + > + +
-
+
diff --git a/vite/src/views/products/plan/ProductSheets.tsx b/vite/src/views/products/plan/ProductSheets.tsx index 9463b03af..68a6be875 100644 --- a/vite/src/views/products/plan/ProductSheets.tsx +++ b/vite/src/views/products/plan/ProductSheets.tsx @@ -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 ( - - {renderSheet()} - + + {sheetType && ( + + + {renderSheet()} + + + )} + ); }; diff --git a/vite/src/views/products/plan/components/EditPlanHeader.tsx b/vite/src/views/products/plan/components/EditPlanHeader.tsx index cb28a0d51..103f72866 100644 --- a/vite/src/views/products/plan/components/EditPlanHeader.tsx +++ b/vite/src/views/products/plan/components/EditPlanHeader.tsx @@ -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} /> -
+
{
- {/* {badgeType && {badgeType}} */} + {product?.id && ( + + )} {product.is_default && Default} {product.is_add_on && Add-on} }> {counts?.active || 0} +
diff --git a/vite/src/views/products/plan/components/PlanToolbar.tsx b/vite/src/views/products/plan/components/PlanToolbar.tsx new file mode 100644 index 000000000..9f17c74de --- /dev/null +++ b/vite/src/views/products/plan/components/PlanToolbar.tsx @@ -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 ( + <> + { + pushPage({ + navigate, + path: "/products", + queryParams: { + tab: "products", + }, + preserveParams: true, + }); + }} + /> + + + } + size="sm" + variant="muted" + iconOrientation="center" + /> + + + setDeleteOpen(true)}> + Delete Plan + + + + + ); +}; diff --git a/vite/src/views/products/plan/components/edit-plan-details/AdditionalOptions.tsx b/vite/src/views/products/plan/components/edit-plan-details/AdditionalOptions.tsx index 800d77d87..fe9c49f15 100644 --- a/vite/src/views/products/plan/components/edit-plan-details/AdditionalOptions.tsx +++ b/vite/src/views/products/plan/components/edit-plan-details/AdditionalOptions.tsx @@ -14,9 +14,9 @@ export const AdditionalOptions = ({
diff --git a/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx b/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx index fd304d6ad..5e26187b8 100644 --- a/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx +++ b/vite/src/views/products/plan/components/edit-plan-details/BasePriceSection.tsx @@ -114,8 +114,7 @@ export const BasePriceSection = ({ description={ A fixed price to charge for the plan. Uncheck this section if the plan - is free or{" "} - a variable price. + is free or only has usage-based prices. } > diff --git a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx index 882714c30..71f993ad5 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx @@ -60,7 +60,7 @@ export function EditPlanFeatureSheet({ )} {feature?.type === FeatureType.Boolean && ( -
+

Nothing to do here...

Boolean features are simply included in the diff --git a/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx b/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx new file mode 100644 index 000000000..38e3b74a4 --- /dev/null +++ b/vite/src/views/products/plan/components/plan-card/BasePriceDisplay.tsx @@ -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 ( +

+ {priceExists ? ( + + {formattedAmount}{" "} + {secondaryText} + + ) : ( + + No base price + + )} +
+ ); +}; diff --git a/vite/src/views/products/plan/components/plan-card/PlanCard.tsx b/vite/src/views/products/plan/components/plan-card/PlanCard.tsx index 243476b55..e90c1a8e4 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanCard.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanCard.tsx @@ -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 ( - + e.stopPropagation()} + >
diff --git a/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx b/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx index fe10b3796..8fef80f90 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanCardHeader.tsx @@ -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 = () => { )} - + {/* } onClick={() => { @@ -56,17 +55,8 @@ export const PlanCardHeader = () => { disabled={true} className="mt-2 !opacity-100 pointer-events-none" > - {productV3.price?.amount ? ( - - ${productV3.price.amount}/ - {keyToTitle(productV3.price.interval ?? "once", { - exclusionMap: { one_off: "once" }, - }).toLowerCase()} - - ) : ( - No price set - )} - + {renderBasePrice()} + */} ); }; diff --git a/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx b/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx index 544cecfdf..3192fcec9 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanCardToolbar.tsx @@ -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 = ({ }} />
- {product?.id && ( - - )} } onClick={onEdit} aria-label="Edit plan" variant="muted" disabled={editDisabled} - iconOrientation="center" + size="sm" className={cn(isEditingPlan && "btn-secondary-active !opacity-100 ")} - /> + > + Edit Details + - {product?.archived ? ( + {/* {product?.archived ? ( @@ -80,7 +73,7 @@ export const PlanCardToolbar = ({ title={deleteDisabled && deleteTooltip ? deleteTooltip : undefined} className={cn(deleteDisabled && "opacity-50 cursor-not-allowed")} /> - )} + )} */}
); diff --git a/vite/src/views/products/plan/planAnimations.ts b/vite/src/views/products/plan/planAnimations.ts new file mode 100644 index 000000000..b743ae16a --- /dev/null +++ b/vite/src/views/products/plan/planAnimations.ts @@ -0,0 +1,4 @@ +export const SHEET_ANIMATION = { + duration: 0.5, + ease: [0.32, 0.72, 0, 1] as const, +} as const;