feat: keystroke

This commit is contained in:
John Yeo
2025-09-24 21:49:09 +01:00
parent 096ce0f121
commit d0463d84fc
13 changed files with 390 additions and 98 deletions

View File

@@ -3,7 +3,11 @@ import type {
ProductItemInterval,
} from "../../../models/productV2Models/productItemModels/productItemModels.js";
import type { ProductV2 } from "../../../models/productV2Models/productV2Models.js";
import { isPriceItem } from "../../productV2Utils/productItemUtils/getItemType.js";
import {
isFeatureItem,
isFeaturePriceItem,
isPriceItem,
} from "../../productV2Utils/productItemUtils/getItemType.js";
export function productV2ToBasePrice({ product }: { product: ProductV2 }): {
amount: number;
@@ -24,3 +28,13 @@ export function productV2ToBasePrice({ product }: { product: ProductV2 }): {
return null;
}
export const productV2ToFeatureItems = ({
items,
}: {
items: ProductItem[];
}) => {
return items.filter(
(item) => isFeatureItem(item) || isFeaturePriceItem(item),
);
};

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import type * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,52 +0,0 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border border-zinc-200 px-2.5 py-0.5 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-zinc-950 focus:ring-offset-2 dark:border-zinc-800 dark:focus:ring-zinc-300",
{
variants: {
variant: {
default:
"border-transparent bg-zinc-900 text-zinc-50 shadow hover:bg-zinc-900/80 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/80",
secondary:
"border-transparent bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
destructive:
"border-transparent bg-red-500 text-zinc-50 shadow hover:bg-red-500/80 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/80",
outline: "text-zinc-950 dark:text-zinc-50",
purple: "bg-purple-50 text-purple-500 border-purple-200",
blue: "bg-blue-50 text-blue-500 border-blue-200",
green:
"bg-green-50 text-green-500 border-green-200 !hover:bg-green-100",
yellow: "bg-yellow-50 text-yellow-500 border-yellow-200",
red: "bg-red-50 text-red-500 border-red-200",
status: "px-1.5 py-0 text-white border-none",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,60 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type * as React from "react";
import { cn } from "@/lib/utils";
// transition-colors focus:outline-none focus:ring-2 focus:ring-zinc-950 focus:ring-offset-2 dark:border-zinc-800 dark:focus:ring-zinc-300
const badgeVariants = cva(
`inline-flex items-center rounded-lg border border-zinc-200 px-1.5 py-0.5
// Custom stuff
text-body-secondary
`,
{
variants: {
variant: {
default:
"border-transparent bg-zinc-900 text-zinc-50 shadow hover:bg-zinc-900/80 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-50/80",
muted: "bg-muted border border-transparent",
// secondary:
// "border-transparent bg-zinc-100 text-zinc-900 hover:bg-zinc-100/80 dark:bg-zinc-800 dark:text-zinc-50 dark:hover:bg-zinc-800/80",
// destructive:
// "border-transparent bg-red-500 text-zinc-50 shadow hover:bg-red-500/80 dark:bg-red-900 dark:text-zinc-50 dark:hover:bg-red-900/80",
// outline: "text-zinc-950 dark:text-zinc-50",
// purple: "bg-purple-50 text-purple-500 border-purple-200",
// blue: "bg-blue-50 text-blue-500 border-blue-200",
// green:
// "bg-green-50 text-green-500 border-green-200 !hover:bg-green-100",
// yellow: "bg-yellow-50 text-yellow-500 border-yellow-200",
// red: "bg-red-50 text-red-500 border-red-200",
// status: "px-1.5 py-0 text-white border-none",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -1,22 +1,118 @@
import type { ReactElement } from "react";
/* eslint-disable react-refresh/only-export-components */
interface IconBadgeProps {
icon: ReactElement;
children: React.ReactNode;
className?: string;
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Badge } from "./Badge";
const iconBadgeVariants = cva(
"flex-row justify-center gap-[3px] min-w-10", // Gap is handled in position variants
{
variants: {
position: {
left: "flex-row",
right: "flex-row-reverse",
},
},
defaultVariants: {
position: "left",
},
},
);
export interface IconBadgeProps
extends Omit<
React.ComponentProps<"span">,
keyof VariantProps<typeof iconBadgeVariants>
>,
VariantProps<typeof iconBadgeVariants> {
icon?: React.ReactNode;
rightIcon?: React.ReactNode;
variant?: "default" | "muted";
asChild?: boolean;
}
export const IconBadge = ({
icon,
children,
className = "",
}: IconBadgeProps) => {
return (
<div
className={`inline-flex items-center gap-2 bg-white border border-border rounded-lg h-[24px] w-fit px-2 py-[3px] shadow-[0px_4px_4px_rgba(0,0,0,0.02),_inset_0px_-3px_4px_rgba(0,0,0,0.04)] ${className}`}
>
<span className="text-t4">{icon}</span>
{children}
</div>
);
};
const IconBadge = React.forwardRef<HTMLSpanElement, IconBadgeProps>(
(
{
className,
variant = "muted",
position = "left",
asChild = false,
icon,
rightIcon,
children,
...props
},
ref,
) => {
const getIconColor = () => {
switch (variant) {
case "muted":
return "text-t4";
case "default":
return "text-zinc-50";
default:
return "text-t4"; // Default fallback
}
};
const renderIcon = (icon: React.ReactNode) => {
if (!icon) return null;
// Clone the icon and add default size and color classes
if (React.isValidElement(icon)) {
return React.cloneElement(icon, {
className: cn("size-3", getIconColor(), icon.props.className),
} as React.HTMLAttributes<HTMLElement>);
}
return icon;
};
const renderContent = () => {
switch (position) {
case "left":
return (
<>
{renderIcon(icon)}
{children}
{renderIcon(rightIcon)}
</>
);
case "right":
return (
<>
{renderIcon(rightIcon)}
{children}
{renderIcon(icon)}
</>
);
default:
return (
<>
{renderIcon(icon)}
{children}
{renderIcon(rightIcon)}
</>
);
}
};
return (
<Badge
ref={ref}
variant={variant}
asChild={asChild}
className={cn(iconBadgeVariants({ position }), className)}
{...props}
>
{renderContent()}
</Badge>
);
},
);
IconBadge.displayName = "IconBadge";
export { IconBadge, iconBadgeVariants };

View File

@@ -16,15 +16,6 @@ export const PlanTypeBadge = ({ product }: { product: any }) => {
if (!badgeType) return null;
return (
// <>
// {product.is_default ? (
// <Badge variant="outline">Default</Badge>
// ) : product.is_add_on ? (
// <Badge variant="outline">Add-On</Badge>
// ) : (
// <></>
// )}
// </>
<Badge
className={cn(
"bg-transparent border border-t1 text-t1 rounded-md px-2 pointer-events-none",

View File

@@ -114,7 +114,7 @@ export const PlanSheets = ({ sheet }: { sheet: Sheets }) => {
};
return (
<div className="w-full max-w-md bg-card z-50 border-l shadow-sm flex flex-col overflow-y-auto h-full">
<div className="w-full min-w-xs max-w-md bg-card z-50 border-l shadow-sm flex flex-col overflow-y-auto h-full">
{renderSheet()}
</div>
);

View File

@@ -1,11 +1,15 @@
import { UserIcon } from "@phosphor-icons/react";
import { AdminHover } from "@/components/general/AdminHover";
import { Badge } from "@/components/v2/badge";
import { Badge } from "@/components/v2/badges/Badge";
import { IconBadge } from "@/components/v2/badges/IconBadge";
import V2Breadcrumb from "@/components/v2/breadcrumb";
import { PlanTypeBadge } from "../../components/PlanTypeBadge";
import { useProductCountsQuery } from "../../product/hooks/queries/useProductCountsQuery";
import { useProductQuery } from "../../product/hooks/useProductQuery";
export const EditPlanHeader = () => {
const { product } = useProductQuery();
const { counts } = useProductCountsQuery();
const getProductAdminHover = () => {
return [
@@ -25,7 +29,7 @@ export const EditPlanHeader = () => {
};
return (
<div className="flex flex-col gap-2 p-4 w-full bg-card">
<div className="flex flex-col gap-2 p-4 pb-3 w-full bg-card">
<V2Breadcrumb
className="p-0"
items={[
@@ -50,7 +54,10 @@ export const EditPlanHeader = () => {
</div>
</div>
<div className="flex flex-row gap-2">
<Badge variant="secondary">Add-on</Badge>
<Badge variant="muted">Add-on</Badge>
<IconBadge variant="muted" icon={<UserIcon />}>
{counts?.active || 0}
</IconBadge>
<PlanTypeBadge product={product} />
</div>
</div>

View File

@@ -7,7 +7,7 @@ export default function EditorTopSection() {
const { product } = useProductContext();
return (
<div className="flex pl-4 pr-10 flex-col gap-2">
<div className="flex pl-4 pt-6 pr-10 flex-col gap-2">
<div className="col-span-2 flex">
<div className="flex flex-row items-baseline justify-start gap-2 w-full whitespace-nowrap">
<AdminHover

View File

@@ -1,4 +1,8 @@
import { type Feature, ProductItemInterval } from "@autumn/shared";
import {
type Feature,
ProductItemInterval,
productV2ToFeatureItems,
} from "@autumn/shared";
import { PlusIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { CustomDialogContent } from "@/components/general/modal-components/DialogContentWrapper";
@@ -10,6 +14,7 @@ import {
} from "@/components/ui/popover";
import { Button } from "@/components/v2/buttons/Button";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { getItemId } from "@/utils/product/productItemUtils";
import { CreateFeature } from "@/views/products/features/components/CreateFeature";
import { FeatureTypeBadge } from "@/views/products/features/components/FeatureTypeBadge";
import { useProductContext } from "@/views/products/product/ProductContext";
@@ -52,9 +57,10 @@ export const AddFeatureRow = ({ disabled }: AddFeatureRowProps) => {
setPopoverOpen(false);
// Open edit sidebar for the new item
const itemIndex = newItems.length;
const itemId =
newItem.entity_feature_id || newItem.feature_id || `item-${itemIndex}`;
const featureItems = productV2ToFeatureItems({ items: newItems });
const itemIndex = featureItems.length - 1;
const itemId = getItemId({ item: newItem, itemIndex });
setEditingState({ type: "feature", id: itemId });
setSheet("edit-feature");
};
@@ -71,7 +77,8 @@ export const AddFeatureRow = ({ disabled }: AddFeatureRowProps) => {
disabled={disabled}
aria-label="Add new feature"
>
<PlusIcon size={16} weight="regular" />
<PlusIcon className="size-3" weight="bold" />
Add feature
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">

View File

@@ -1,8 +1,18 @@
import { useHotkeys } from "react-hotkeys-hook";
import { Card, CardContent } from "@/components/v2/cards/Card";
import { useFeatureNavigation } from "./hooks/useFeatureNavigation";
import { PlanCardHeader } from "./PlanCardHeader";
import { PlanFeatureList } from "./PlanFeatureList";
export default function PlanCard() {
// Initialize feature navigation (registers hotkeys internally)
useFeatureNavigation();
useHotkeys("ctrl+s", () => {
console.log("Save");
});
return (
<Card className="min-w-sm w-[70%] max-w-xl mx-4 bg-card">
<PlanCardHeader />

View File

@@ -1,5 +1,4 @@
import type { ProductItem } from "@autumn/shared";
import { isPriceItem } from "@/utils/product/getItemType";
import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared";
import { useProductContext } from "@/views/products/product/ProductContext";
import { AddFeatureRow } from "./AddFeatureRow";
import { PlanFeatureRow } from "./PlanFeatureRow";
@@ -8,6 +7,8 @@ export const PlanFeatureList = () => {
const { product, setProduct, setSheet, editingState, setEditingState } =
useProductContext();
const filteredItems = productV2ToFeatureItems({ items: product?.items });
const handleFeatureClick = (item: ProductItem) => {
console.log("Feature clicked:", item);
};
@@ -45,10 +46,6 @@ export const PlanFeatureList = () => {
setSheet("edit-feature");
};
// Filter out standalone price items - only show features and priced features
const filteredItems =
product?.items?.filter((item: ProductItem) => !isPriceItem(item)) || [];
if (filteredItems.length === 0) {
return (
<div className="space-y-1">

View File

@@ -0,0 +1,162 @@
import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared";
import { useCallback, useEffect, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { getItemId } from "@/utils/product/productItemUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
export const useFeatureNavigation = () => {
const { product, editingState, setEditingState, setSheet } =
useProductContext();
const [selectedIndex, setSelectedIndex] = useState<number>(0);
// Get filtered items (non-price items)
const filteredItems = productV2ToFeatureItems({ items: product?.items });
// const filteredItems = useMemo(() => {
// return productV2ToFeatureItems({ items: product?.items });
// }, [product?.items]);
// Update selected index when editing state changes or items change
useEffect(() => {
if (editingState.id && filteredItems.length > 0) {
const currentIndex = filteredItems.findIndex(
(item: ProductItem, index: number) => {
const itemId = getItemId({ item, itemIndex: index });
return itemId === editingState.id;
},
);
if (currentIndex !== -1) {
setSelectedIndex(currentIndex);
}
} else if (filteredItems.length === 0) {
// Reset selection when no items
setSelectedIndex(0);
}
}, [editingState.id, filteredItems]);
// Handle when selected index becomes out of bounds (e.g., item deleted)
useEffect(() => {
if (selectedIndex >= filteredItems.length && filteredItems.length > 0) {
setSelectedIndex(filteredItems.length - 1);
}
}, [selectedIndex, filteredItems.length]);
// Navigation functions
const navigateToIndex = useCallback(
(index: number) => {
if (filteredItems.length === 0) return;
const clampedIndex = Math.max(
0,
Math.min(index, filteredItems.length - 1),
);
setSelectedIndex(clampedIndex);
const item = filteredItems[clampedIndex];
if (item) {
const itemId = getItemId({ item, itemIndex: clampedIndex });
setEditingState({ type: "feature", id: itemId });
setSheet("edit-feature");
}
},
[filteredItems, setEditingState, setSheet],
);
const navigateUp = useCallback(() => {
navigateToIndex(selectedIndex - 1);
}, [selectedIndex, navigateToIndex]);
const navigateDown = useCallback(() => {
navigateToIndex(selectedIndex + 1);
}, [selectedIndex, navigateToIndex]);
const selectFirst = useCallback(() => {
navigateToIndex(0);
}, [navigateToIndex]);
const selectLast = useCallback(() => {
navigateToIndex(filteredItems.length - 1);
}, [filteredItems.length, navigateToIndex]);
const editPlan = useCallback(() => {
if (!product) return;
setEditingState({ type: "plan", id: product.id });
setSheet("edit-plan");
}, [product, setEditingState, setSheet]);
// Register hotkeys
useHotkeys("up", navigateUp, {
preventDefault: true,
enabled: filteredItems.length > 0,
});
useHotkeys("down", navigateDown, {
preventDefault: true,
enabled: filteredItems.length > 0,
});
useHotkeys("home", selectFirst, {
preventDefault: true,
enabled: filteredItems.length > 0,
});
useHotkeys("end", selectLast, {
preventDefault: true,
enabled: filteredItems.length > 0,
});
// 0 key to edit plan
useHotkeys("0", editPlan, {
preventDefault: true,
enabled: !!product,
});
// Number key navigation (1-9) - using individual hooks to avoid loop issues
useHotkeys("1", () => navigateToIndex(0), {
preventDefault: true,
enabled: filteredItems.length >= 1,
});
useHotkeys("2", () => navigateToIndex(1), {
preventDefault: true,
enabled: filteredItems.length >= 2,
});
useHotkeys("3", () => navigateToIndex(2), {
preventDefault: true,
enabled: filteredItems.length >= 3,
});
useHotkeys("4", () => navigateToIndex(3), {
preventDefault: true,
enabled: filteredItems.length >= 4,
});
useHotkeys("5", () => navigateToIndex(4), {
preventDefault: true,
enabled: filteredItems.length >= 5,
});
useHotkeys("6", () => navigateToIndex(5), {
preventDefault: true,
enabled: filteredItems.length >= 6,
});
useHotkeys("7", () => navigateToIndex(6), {
preventDefault: true,
enabled: filteredItems.length >= 7,
});
useHotkeys("8", () => navigateToIndex(7), {
preventDefault: true,
enabled: filteredItems.length >= 8,
});
useHotkeys("9", () => navigateToIndex(8), {
preventDefault: true,
enabled: filteredItems.length >= 9,
});
return {
selectedIndex,
filteredItems,
navigateToIndex,
navigateUp,
navigateDown,
selectFirst,
selectLast,
editPlan,
hasItems: filteredItems.length > 0,
};
};