From 2bb22d9ff05ab19ea5c13e9432bd3819469b4f10 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Tue, 23 Sep 2025 18:50:07 +0100 Subject: [PATCH] added save button --- .../productItemModels/productItemModels.ts | 5 +- .../compareProductUtils.ts | 7 +- vite/src/components/ui/button.tsx | 32 +- vite/src/components/v2/button.tsx | 957 ------------------ vite/src/components/v2/buttons/Button.tsx | 83 +- .../components/v2/buttons/ShortcutButton.tsx | 43 + vite/src/components/v2/inputs/LabelInput.tsx | 2 +- vite/src/utils/genUtils.ts | 43 +- vite/src/utils/supabase/client.ts | 4 +- .../views/products/plan/PlanEditorView.tsx | 9 +- .../components/PlanCard/AddFeatureRow.tsx | 4 +- .../plan/components/PlanCard/PlanCard.tsx | 2 +- .../components/PlanCard/PlanFeatureList.tsx | 3 - .../components/PlanCard/PlanFeatureRow.tsx | 5 +- .../products/plan/components/SaveChanges.tsx | 31 - .../plan/components/SaveChangesBar.tsx | 62 ++ .../edit-plan-details/BasePriceSection.tsx | 17 +- .../plan/utils/validateItemsBeforeSave.ts | 14 + .../products/product/utils/updateProduct.ts | 23 +- 19 files changed, 264 insertions(+), 1082 deletions(-) delete mode 100644 vite/src/components/v2/button.tsx create mode 100644 vite/src/components/v2/buttons/ShortcutButton.tsx delete mode 100644 vite/src/views/products/plan/components/SaveChanges.tsx create mode 100644 vite/src/views/products/plan/components/SaveChangesBar.tsx create mode 100644 vite/src/views/products/plan/utils/validateItemsBeforeSave.ts diff --git a/shared/models/productV2Models/productItemModels/productItemModels.ts b/shared/models/productV2Models/productItemModels/productItemModels.ts index 0746f6610..b52d337bc 100644 --- a/shared/models/productV2Models/productItemModels/productItemModels.ts +++ b/shared/models/productV2Models/productItemModels/productItemModels.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { Infinite } from "../../productModels/productEnums.js"; -import { OnIncrease } from "./productItemEnums.js"; -import { OnDecrease } from "./productItemEnums.js"; +import { OnDecrease, OnIncrease } from "./productItemEnums.js"; export const TierInfinite = "inf"; @@ -95,8 +94,10 @@ export const LimitedItemSchema = ProductItemSchema.extend({ }); export const FrontendProductItem = ProductItemSchema.extend({ + price: z.union([z.number(), z.string()]).nullish(), isPrice: z.boolean(), isVariable: z.boolean().nullish(), + isBasePrice: z.boolean().nullish(), }); export type ProductItem = z.infer; diff --git a/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts b/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts index ae8dcf7c3..ffec7d7f1 100644 --- a/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts +++ b/shared/utils/productV2Utils/compareProductUtils.ts/compareProductUtils.ts @@ -85,9 +85,6 @@ export const productsAreSame = ({ items1 = sanitizeItems({ items: items1, features }); items2 = sanitizeItems({ items: items2, features }); - console.log("Items 1:", items1); - console.log("Items 2:", items2); - let itemsSame = true; let pricesChanged = false; const newItems: ProductItem[] = []; @@ -113,14 +110,14 @@ export const productsAreSame = ({ if (items1.length !== items2.length) itemsSame = false; for (const item of items1) { - console.log("Item:", item); + // console.log("Item:", item); const similarItem = findSimilarItem({ item, items: items2, }); - console.log("Similar item:", similarItem); + // console.log("Similar item:", similarItem); if (!similarItem) { if (isFeaturePriceItem(item) || isPriceItem(item)) { diff --git a/vite/src/components/ui/button.tsx b/vite/src/components/ui/button.tsx index f64c25d14..8885173b8 100644 --- a/vite/src/components/ui/button.tsx +++ b/vite/src/components/ui/button.tsx @@ -1,11 +1,9 @@ -import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; - -import { cn } from "@/lib/utils"; import { LoaderCircle, PlusIcon, Search } from "lucide-react"; -import { Tooltip, TooltipContent } from "./tooltip"; -import { TooltipTrigger } from "./tooltip"; +import * as React from "react"; +import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip"; // [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 @@ -153,30 +151,6 @@ const Button = React.forwardRef( } else { return Button; } - return ( - { - if (isLoading) { - e.preventDefault(); - e.stopPropagation(); - return; - } - props.onClick?.(e); - }} - > - {isLoading && } - {startIcon && !isLoading && <>{startIcon}} - {variant == "add" && } - {children} - {endIcon && !isLoading && <>{endIcon}} - - ); }, ); Button.displayName = "Button"; diff --git a/vite/src/components/v2/button.tsx b/vite/src/components/v2/button.tsx deleted file mode 100644 index 93083267f..000000000 --- a/vite/src/components/v2/button.tsx +++ /dev/null @@ -1,957 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -; diff --git a/vite/src/components/v2/buttons/Button.tsx b/vite/src/components/v2/buttons/Button.tsx index 8715eb29b..cf40b2bb7 100644 --- a/vite/src/components/v2/buttons/Button.tsx +++ b/vite/src/components/v2/buttons/Button.tsx @@ -1,14 +1,14 @@ /* eslint-disable react-refresh/only-export-components */ import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; -import type * as React from "react"; - +import * as React from "react"; +import SmallSpinner from "@/components/general/SmallSpinner"; import { cn } from "@/lib/utils"; const buttonVariants = cva( `inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-none - h-6 rounded-lg + h-6 rounded-lg group/btn `, { variants: { @@ -16,24 +16,12 @@ const buttonVariants = cva( // Custom primary: `btn-primary-shadow !text-white bg-primary border border-transparent hover:bg-primary-btn-hover focus:bg-primary-btn-active focus:border-primary-btn-border`, + secondary: "bg-white border border-input hover:border-primary hover:bg-hover-primary focus:bg-active-primary focus:border-primary btn-secondary-shadow", - - // destructive: - // "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", - // outline: - // "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", - // secondary: - // "bg-secondary text-secondary-foreground hover:bg-secondary/80", - // ghost: - // "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", - // link: "text-primary underline-offset-4 hover:underline", }, size: { default: "py-1 !px-[7px] text-body has-[>svg]:px-3", - // sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", - // lg: "h-10 rounded-md px-6 has-[>svg]:px-4", - // icon: "size-9", }, }, defaultVariants: { @@ -43,25 +31,78 @@ const buttonVariants = cva( }, ); +export interface ButtonProps + extends React.ComponentProps<"button">, + VariantProps { + asChild?: boolean; + isLoading?: boolean; +} + function Button({ className, variant, size, asChild = false, + isLoading = false, + children, ...props -}: React.ComponentProps<"button"> & - VariantProps & { - asChild?: boolean; - }) { +}: ButtonProps) { const Comp = asChild ? Slot : "button"; + const buttonRef = React.useRef(null); + const [contentWidth, setContentWidth] = React.useState(null); + + React.useEffect(() => { + if (buttonRef.current && !isLoading) { + // Measure the full button width including padding + const width = buttonRef.current.offsetWidth; + setContentWidth(width); + } + }, [isLoading]); + + // Measure width on mount and when loading state changes + React.useEffect(() => { + if (buttonRef.current && !isLoading) { + // Use requestAnimationFrame to ensure DOM is fully rendered + requestAnimationFrame(() => { + if (buttonRef.current) { + const width = buttonRef.current.offsetWidth; + setContentWidth(width); + } + }); + } + }, [isLoading]); return ( + > + {isLoading ? : children} + ); } export { Button, buttonVariants }; + +// destructive: +// "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", +// outline: +// "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", +// secondary: +// "bg-secondary text-secondary-foreground hover:bg-secondary/80", +// ghost: +// "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", +// link: "text-primary underline-offset-4 hover:underline", + +// sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", +// lg: "h-10 rounded-md px-6 has-[>svg]:px-4", +// icon: "size-9", diff --git a/vite/src/components/v2/buttons/ShortcutButton.tsx b/vite/src/components/v2/buttons/ShortcutButton.tsx new file mode 100644 index 000000000..5346b5e57 --- /dev/null +++ b/vite/src/components/v2/buttons/ShortcutButton.tsx @@ -0,0 +1,43 @@ +import { useHotkeys } from "react-hotkeys-hook"; +import { Button, type ButtonProps } from "@/components/v2/buttons/Button"; + +export const ShortcutButton = ({ + metaShortcut, + children, + isLoading, + ...props +}: { + metaShortcut: string; + children: React.ReactNode; + isLoading?: boolean; +} & ButtonProps) => { + const getMetaKey = () => { + if (navigator.userAgent.includes("Mac")) { + return "⌘"; + } + return "Ctrl"; + }; + + useHotkeys([`meta+${metaShortcut}`], (e) => { + e.preventDefault(); + props?.onClick?.(e as unknown as React.MouseEvent); + }); + + const keystrokeContainer = (keyStroke: string) => ( +
+ {keyStroke} +
+ ); + + return ( + + ); +}; diff --git a/vite/src/components/v2/inputs/LabelInput.tsx b/vite/src/components/v2/inputs/LabelInput.tsx index 8c005ed8b..b1194ffc0 100644 --- a/vite/src/components/v2/inputs/LabelInput.tsx +++ b/vite/src/components/v2/inputs/LabelInput.tsx @@ -1,6 +1,6 @@ import type React from "react"; import { useId } from "react"; -import { Input } from "./input"; +import { Input } from "./Input"; export const LabelInput = ({ label, diff --git a/vite/src/utils/genUtils.ts b/vite/src/utils/genUtils.ts index aa0c9608c..1f2941934 100644 --- a/vite/src/utils/genUtils.ts +++ b/vite/src/utils/genUtils.ts @@ -1,17 +1,19 @@ import { AppEnv } from "@autumn/shared"; +import type { AxiosError } from "axios"; +import type { NavigateFunction } from "react-router-dom"; export const compareStatus = (statusA: string, statusB: string) => { const statusOrder = ["scheduled", "active", "past_due", "expired"]; return statusOrder.indexOf(statusA) - statusOrder.indexOf(statusB); }; -export const invalidNumber = (value: any) => { - return isNaN(parseFloat(value)); +export const invalidNumber = (value: unknown) => { + return Number.isNaN(parseFloat(value as string)); }; -export const getBackendErr = (error: any, defaultText: string) => { - if (error.response && error.response.data) { - const data = error.response.data; +export const getBackendErr = (error: AxiosError, defaultText: string) => { + if (error.response?.data) { + const data = error.response.data as { message: string; code: string }; if (data.message && data.code) { return data.message; } else { @@ -22,9 +24,9 @@ export const getBackendErr = (error: any, defaultText: string) => { } }; -export const getBackendErrObj = (error: any) => { - if (error.response && error.response.data) { - const data = error.response.data; +export const getBackendErrObj = (error: AxiosError) => { + if (error.response?.data) { + const data = error.response.data as { code: string; message: string }; if (data.code) { return { code: data.code, message: data.message }; } @@ -49,7 +51,11 @@ export const envToPath = (env: AppEnv, currentPath: string) => { return null; }; -export const navigateTo = (path: string, navigate: any, env?: AppEnv) => { +export const navigateTo = ( + path: string, + navigate: NavigateFunction, + env?: AppEnv, +) => { const curPath = window.location.pathname; const curEnv = getEnvFromPath(curPath); @@ -69,7 +75,7 @@ export const pushPage = ({ }: { path: string; queryParams?: Record; - navigate?: any; + navigate?: NavigateFunction; preserveParams?: boolean; }) => { const pathname = window.location.pathname; @@ -117,10 +123,23 @@ export const getRedirectUrl = (path: string, env: AppEnv) => { } }; -export const notNullish = (value: any) => { +export const notNullish = (value: unknown) => { return value !== null && value !== undefined; }; -export const nullish = (value: any) => { +export const nullish = (value: unknown) => { return value === null || value === undefined; }; + +export const parseNumberInput = ({ + value, + fallback = 0, +}: { + value?: string; + fallback?: number; +}): number | null => { + if (value === undefined) return fallback; + + const numValue = Number.parseFloat(value); + return Number.isNaN(numValue) ? fallback : numValue; +}; diff --git a/vite/src/utils/supabase/client.ts b/vite/src/utils/supabase/client.ts index 5afaa1fa0..4f693958f 100644 --- a/vite/src/utils/supabase/client.ts +++ b/vite/src/utils/supabase/client.ts @@ -2,7 +2,7 @@ import { createBrowserClient } from "@supabase/ssr"; export function createClient() { return createBrowserClient( - import.meta.env.VITE_SUPABASE_URL!, - import.meta.env.VITE_SUPABASE_ANON_KEY!, + import.meta.env.VITE_SUPABASE_URL || "", + import.meta.env.VITE_SUPABASE_ANON_KEY || "", ); } diff --git a/vite/src/views/products/plan/PlanEditorView.tsx b/vite/src/views/products/plan/PlanEditorView.tsx index fa86f0766..e06446b49 100644 --- a/vite/src/views/products/plan/PlanEditorView.tsx +++ b/vite/src/views/products/plan/PlanEditorView.tsx @@ -11,7 +11,7 @@ import { ManagePlan } from "./components/Editor"; import { EditPlanHeader } from "./components/EditPlanHeader"; import { EditPlanItemSheet } from "./components/EditPlanItemSheet"; import { EditPlanSheet } from "./components/EditPlanSheet"; -import { SaveChangesBar } from "./components/SaveChanges"; +import { SaveChangesBar } from "./components/SaveChangesBar"; import { usePlanData } from "./hooks/usePlanData"; type Sheets = "edit-plan" | "edit-feature" | null; @@ -62,7 +62,7 @@ export default function PlanEditorView() { setOpen={setShowNewVersionDialog} />
-
+
{/*
*/} @@ -117,10 +117,7 @@ export const PlanSheets = ({ sheet }: { sheet: Sheets }) => { }; return ( -
+
{renderSheet()}
); diff --git a/vite/src/views/products/plan/components/PlanCard/AddFeatureRow.tsx b/vite/src/views/products/plan/components/PlanCard/AddFeatureRow.tsx index 213534660..98221aebc 100644 --- a/vite/src/views/products/plan/components/PlanCard/AddFeatureRow.tsx +++ b/vite/src/views/products/plan/components/PlanCard/AddFeatureRow.tsx @@ -9,7 +9,9 @@ export const AddFeatureRow = ({ onClick, disabled }: AddFeatureRowProps) => { return ( - -
-
- ); -}; diff --git a/vite/src/views/products/plan/components/SaveChangesBar.tsx b/vite/src/views/products/plan/components/SaveChangesBar.tsx new file mode 100644 index 000000000..122bf1a2f --- /dev/null +++ b/vite/src/views/products/plan/components/SaveChangesBar.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/v2/buttons/Button"; +import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { useProductCountsQuery } from "../../product/hooks/queries/useProductCountsQuery"; +import { useProductQuery } from "../../product/hooks/useProductQuery"; +import { useProductContext } from "../../product/ProductContext"; +import { updateProduct } from "../../product/utils/updateProduct"; + +export const SaveChangesBar = () => { + const { hasChanges } = useProductContext(); + const axiosInstance = useAxiosInstance(); + const [saving, setSaving] = useState(false); + const { product, setShowNewVersionDialog } = useProductContext(); + const { counts, isLoading } = useProductCountsQuery(); + const { refetch } = useProductQuery(); + + const handleSaveClicked = async () => { + if (isLoading) toast.error("Product counts are loading"); + + if (counts?.all > 0) { + setShowNewVersionDialog(true); + return; + } + + setSaving(true); + await updateProduct({ + axiosInstance, + product, + onSuccess: async () => { + await refetch(); + }, + }); + + setSaving(false); + }; + + const handleDiscardClicked = () => { + // TODO: Implement discard functionality + }; + + if (!hasChanges) return null; + + return ( +
+
+

You have unsaved changes

+ + + Save + +
+
+ ); +}; 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 0cba14504..c9e452f89 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 @@ -2,6 +2,7 @@ import { BillingInterval, billingToItemInterval, isPriceItem, + notNullish, nullish, type ProductItem, productV2ToBasePrice, @@ -36,7 +37,7 @@ export const BasePriceSection = () => { amount, interval, }: { - amount?: number; + amount?: string; interval?: BillingInterval; }) => { const newItems = [...product.items]; @@ -45,14 +46,24 @@ export const BasePriceSection = () => { ); if (basePriceIndex !== -1) { + const newAmount = + amount === "" + ? amount + : notNullish(amount) + ? Number.parseFloat(amount ?? "") + : basePrice?.amount; + newItems[basePriceIndex] = { ...newItems[basePriceIndex], - price: amount ?? basePrice?.amount, + price: newAmount, interval: interval ? billingToItemInterval(interval) : basePrice?.interval, + + isBasePrice: true, }; } + setProduct({ ...product, items: newItems, @@ -94,7 +105,7 @@ export const BasePriceSection = () => { value={basePrice?.amount ?? ""} onChange={(e) => { handleUpdateBasePrice({ - amount: e.target.value as unknown as number, + amount: e.target.value, }); }} /> diff --git a/vite/src/views/products/plan/utils/validateItemsBeforeSave.ts b/vite/src/views/products/plan/utils/validateItemsBeforeSave.ts new file mode 100644 index 000000000..311ecde94 --- /dev/null +++ b/vite/src/views/products/plan/utils/validateItemsBeforeSave.ts @@ -0,0 +1,14 @@ +import type { FrontendProductItem } from "@autumn/shared"; +import { toast } from "sonner"; + +export const validateItemsBeforeSave = (items: FrontendProductItem[]) => { + for (const item of items) { + if (item.isBasePrice) { + if (typeof item.price !== "number" && !item.price) { + toast.error("Base price cannot be empty"); + return false; + } + } + } + return true; +}; diff --git a/vite/src/views/products/product/utils/updateProduct.ts b/vite/src/views/products/product/utils/updateProduct.ts index 6d172ce8a..a280c1cb8 100644 --- a/vite/src/views/products/product/utils/updateProduct.ts +++ b/vite/src/views/products/product/utils/updateProduct.ts @@ -1,22 +1,31 @@ -import { type ProductV2, UpdateProductSchema } from "@autumn/shared"; -import type { AxiosInstance } from "axios"; +import { + type FrontendProductItem, + type ProductV2, + UpdateProductSchema, +} from "@autumn/shared"; +import type { AxiosError, AxiosInstance } from "axios"; import { toast } from "sonner"; import { ProductService } from "@/services/products/ProductService"; import { getBackendErr } from "@/utils/genUtils"; +import { validateItemsBeforeSave } from "../../plan/utils/validateItemsBeforeSave"; export const updateProduct = async ({ axiosInstance, product, onSuccess, - // mutate, - // mutateCount, }: { axiosInstance: AxiosInstance; product: ProductV2; onSuccess: () => Promise; - // mutate: () => void; - // mutateCount: () => void; }) => { + const validated = validateItemsBeforeSave( + product.items as FrontendProductItem[], + ); + + console.log("validated", validated); + if (!validated) { + return false; + } try { await ProductService.updateProduct(axiosInstance, product.id, { ...UpdateProductSchema.parse(product), @@ -30,7 +39,7 @@ export const updateProduct = async ({ return true; } catch (error) { console.error(error); - toast.error(getBackendErr(error, "Failed to update product")); + toast.error(getBackendErr(error as AxiosError, "Failed to update product")); return false; } };