diff --git a/bun.lock b/bun.lock index a791eaf32..11608dd72 100644 --- a/bun.lock +++ b/bun.lock @@ -149,6 +149,7 @@ "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "@heroicons/react": "^2.2.0", + "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-context-menu": "^2.2.16", @@ -162,6 +163,9 @@ "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", + "@squircle/core": "^1.0.6", + "@squircle/paint-polyfill": "^1.0.6", + "@squircle/tailwindcss": "^1.0.6", "@supabase/ssr": "^0.5.2", "@tailwindcss/vite": "^4.0.13", "@tanstack/react-query": "^5.85.6", @@ -796,6 +800,8 @@ "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.4.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.4.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-F7mIZY2Eao2TaoVqigGMLv+NDdpwuBKU1fucHPONfzaBS4JXXCNCmfO0Z3dsy7JzKGqtDcYC1mr9JjaZQZNiuw=="], + "@phosphor-icons/react": ["@phosphor-icons/react@2.1.10", "", { "peerDependencies": { "react": ">= 16.8", "react-dom": ">= 16.8" } }, "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@prisma/instrumentation": ["@prisma/instrumentation@6.11.1", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-mrZOev24EDhnefmnZX7WVVT7v+r9LttPRqf54ONvj6re4XMF7wFTpK2tLJi4XHB7fFp/6xhYbgRel8YV7gQiyA=="], @@ -1010,6 +1016,12 @@ "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@squircle/core": ["@squircle/core@1.0.6", "", { "peerDependencies": { "@squircle/paint-polyfill": "^1.0.3" } }, "sha512-jvAIehtarEHBl6W80LNiZ9KWVKdJw6jGj/avwwbCoGVs9t+AUA5YVmUyAtEasFtoocHwxbbRsdnTeKxQ+061aQ=="], + + "@squircle/paint-polyfill": ["@squircle/paint-polyfill@1.0.6", "", {}, "sha512-w3FotA1v53MG+R6eGM37wv1aVoW3jwymwGteGCGXEM10Gq7zYeCus5dTgcyW6fhc9piRqsFLyU8MIl+EbtM9RA=="], + + "@squircle/tailwindcss": ["@squircle/tailwindcss@1.0.6", "", {}, "sha512-tmOWdpX7lzBAJzuvajn4NVBtC7mVORmzf/jKREYMw8iJ806Xq4FCUkrtbxkd9blzOx7TG2JwLF3avh5It/Z6TQ=="], + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], diff --git a/shared/index.ts b/shared/index.ts index 4ee0eaaeb..6a55c82ff 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -108,6 +108,8 @@ export * from "./models/productV2Models/productItemModels/productItemModels.js"; export * from "./models/productV2Models/productResponseModels.js"; // 6. Product V2 Models export * from "./models/productV2Models/productV2Models.js"; +// 7. Product V3 Models +export * from "./models/productV3Models/productV3Response.js"; export * from "./models/rewardModels/referralModels/referralCodeTable.js"; export * from "./models/rewardModels/referralModels/referralModels.js"; export * from "./models/rewardModels/referralModels/rewardRedemptionTable.js"; @@ -127,6 +129,8 @@ export * from "./utils/index.js"; export * from "./utils/intervalUtils.js"; export * from "./utils/productDisplayUtils/getProductItemRes.js"; export * from "./utils/productDisplayUtils/sortProductItems.js"; +export * from "./utils/productDisplayUtils.js"; export * from "./utils/productUtils/priceToInvoiceAmount.js"; export * from "./utils/productUtils.js"; +export * from "./utils/productV3Utils/index.js"; export * from "./utils/rewardUtils/rewardMigrationUtils.js"; diff --git a/shared/models/productV3Models/productV3Response.ts b/shared/models/productV3Models/productV3Response.ts index 94fdf449b..514b8fa3d 100644 --- a/shared/models/productV3Models/productV3Response.ts +++ b/shared/models/productV3Models/productV3Response.ts @@ -1,33 +1,34 @@ import { z } from "zod"; -import { AppEnv } from "../genModels/genEnums.js"; import { AttachScenario } from "../checkModels/checkPreviewModels.js"; -import { ProductPropertiesSchema } from "../productV2Models/productResponseModels.js"; +import { AppEnv } from "../genModels/genEnums.js"; import { ProductItemInterval } from "../productV2Models/productItemModels/productItemModels.js"; +import { ProductPropertiesSchema } from "../productV2Models/productResponseModels.js"; export const PlanResponseSchema = z.object({ - id: z.string(), - name: z.string().nullable(), - group: z.string().nullable(), - env: z.nativeEnum(AppEnv), - is_add_on: z.boolean(), - is_default: z.boolean(), - archived: z.boolean().optional(), - version: z.number(), - created_at: z.number(), + id: z.string(), + name: z.string().nullable(), + description: z.string().nullable(), + group: z.string().nullable(), + env: z.nativeEnum(AppEnv), + is_add_on: z.boolean(), + is_default: z.boolean(), + archived: z.boolean().optional(), + version: z.number(), + created_at: z.number(), - price: z - .object({ - amount: z.number(), - interval: z.nativeEnum(ProductItemInterval).nullable(), - }) - .nullable(), + price: z + .object({ + amount: z.number(), + interval: z.nativeEnum(ProductItemInterval).nullable(), + }) + .nullable(), - // features: + // features: - // items: z.array(ProductItemResponseSchema), - // free_trial: FreeTrialResponseSchema.nullable(), - // base_variant_id: z.string().nullable(), + // items: z.array(ProductItemResponseSchema), + // free_trial: FreeTrialResponseSchema.nullable(), + // base_variant_id: z.string().nullable(), - scenario: z.nativeEnum(AttachScenario).optional(), - properties: ProductPropertiesSchema.optional(), + scenario: z.nativeEnum(AttachScenario).optional(), + properties: ProductPropertiesSchema.optional(), }); diff --git a/shared/utils/productV3Utils/index.ts b/shared/utils/productV3Utils/index.ts new file mode 100644 index 000000000..69948e53e --- /dev/null +++ b/shared/utils/productV3Utils/index.ts @@ -0,0 +1,29 @@ +import type { z } from "zod"; +import type { AppEnv } from "../../models/genModels/genEnums.js"; +import type { ProductV2 } from "../../models/productV2Models/productV2Models.js"; +import type { PlanResponseSchema } from "../../models/productV3Models/productV3Response.js"; +import { extractTopLevelPrice } from "./productItemUtils/productItemUtils.js"; + +export function mapToProductV3({ + product, + env, +}: { + product: ProductV2; + env: AppEnv; +}): z.infer { + const productV3 = { + id: product.id, + name: product.name, + group: product.group, + env: env, + is_add_on: product.is_add_on, + is_default: product.is_default, + version: product.version, + description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + created_at: product.created_at, + price: extractTopLevelPrice(product) || null, + }; + return productV3; +} + +export * from "./productItemUtils/productItemUtils.js"; \ No newline at end of file diff --git a/shared/utils/productV3Utils/productItemUtils/productItemUtils.ts b/shared/utils/productV3Utils/productItemUtils/productItemUtils.ts new file mode 100644 index 000000000..95df3b63a --- /dev/null +++ b/shared/utils/productV3Utils/productItemUtils/productItemUtils.ts @@ -0,0 +1,40 @@ +import { ProductItemInterval } from "../../../models/productV2Models/productItemModels/productItemModels.js"; +import type { ProductV2 } from "../../../models/productV2Models/productV2Models.js"; + +export function extractTopLevelPrice( + product: ProductV2, +): { amount: number; interval: ProductItemInterval } | null { + try { + // First try to find an item with price_config + let item = product.items.find( + (x) => + x.price_config?.type === "fixed" && x.price_config?.amount !== null, + ); + + // If no price_config, try to find an item with direct price + if (!item) { + item = product.items.find( + (x) => x.price !== null && x.price !== undefined, + ); + } + + if(item) { + // Prefer price_config if available, otherwise use direct price + if (item.price_config?.type === "fixed" && item.price_config?.amount !== null) { + return { + amount: item.price_config.amount, + interval: item.price_config.interval, + }; + } else if (item.price !== null && item.price !== undefined) { + return { + amount: item.price, + interval: ProductItemInterval.Month, // Default interval if not specified + }; + } + } + } catch (error: unknown) { + console.error("Error extracting price:", error); + return null; + } + return null; +} diff --git a/temp_svg_analysis.svg b/temp_svg_analysis.svg new file mode 100644 index 000000000..306a19148 --- /dev/null +++ b/temp_svg_analysis.svg @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vite/package.json b/vite/package.json index 837371e2c..c324b2b73 100644 --- a/vite/package.json +++ b/vite/package.json @@ -22,6 +22,7 @@ "@fortawesome/free-brands-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", "@heroicons/react": "^2.2.0", + "@phosphor-icons/react": "^2.1.10", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-context-menu": "^2.2.16", @@ -35,6 +36,9 @@ "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tooltip": "^1.1.8", + "@squircle/core": "^1.0.6", + "@squircle/paint-polyfill": "^1.0.6", + "@squircle/tailwindcss": "^1.0.6", "@supabase/ssr": "^0.5.2", "@tailwindcss/vite": "^4.0.13", "@tanstack/react-query": "^5.85.6", diff --git a/vite/src/App.tsx b/vite/src/App.tsx index 55b752ff0..c28ca164d 100644 --- a/vite/src/App.tsx +++ b/vite/src/App.tsx @@ -1,4 +1,6 @@ import { AppEnv } from "@autumn/shared"; +import { init } from "@squircle/core"; +import * as React from "react"; import { BrowserRouter, Route, Routes } from "react-router"; import { MainLayout } from "./app/layout"; import { AdminView } from "./views/admin/AdminView"; @@ -18,6 +20,11 @@ import ProductsView from "./views/products/ProductsView"; import PlanEditorView from "./views/products/plan/PlanEditorView"; import { TerminalView } from "./views/TerminalView"; +export function SquircleProvider({ children }: { children: React.ReactNode }) { + React.useEffect(() => void init(), []); + return children; +} + export default function App() { return ( @@ -41,10 +48,21 @@ export default function App() { path="/sandbox/products" element={} /> - } /> + + + + } + /> } + element={ + + + + } /> } /> diff --git a/vite/src/components/general/CopyablePre.tsx b/vite/src/components/general/CopyablePre.tsx index 68aa99a69..9ee09e983 100644 --- a/vite/src/components/general/CopyablePre.tsx +++ b/vite/src/components/general/CopyablePre.tsx @@ -14,19 +14,33 @@ export function CopyablePre({ text }: { text: string }) { } // A compact, inline version for use in places like badges or small UI elements. -export function CopyableSpan({ text, className = "" }: { text: string; className?: string }) { +export function CopyableSpan({ + text, + className = "", + copySize = 20, + copyProps = {} +}: { + text: string; + className?: string; + copySize?: number; + copyProps?: React.ComponentProps; +}) { return ( {text} ); diff --git a/vite/src/components/v2/button.tsx b/vite/src/components/v2/button.tsx index e69de29bb..fc14ea013 100644 --- a/vite/src/components/v2/button.tsx +++ b/vite/src/components/v2/button.tsx @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vite/src/components/v2/buttons/IconButton.tsx b/vite/src/components/v2/buttons/IconButton.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/vite/src/components/v2/buttons/IconPillButton.tsx b/vite/src/components/v2/buttons/IconPillButton.tsx new file mode 100644 index 000000000..3751f143e --- /dev/null +++ b/vite/src/components/v2/buttons/IconPillButton.tsx @@ -0,0 +1,42 @@ +import { Button } from "@/components/ui/button"; +import type { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface IconPillButtonProps { + icon: LucideIcon; + text: string; + onClick?: () => void; + className?: string; + disabled?: boolean; + variant?: "default" | "muted"; +} + +export const IconPillButton = ({ + icon: Icon, + text, + onClick, + className, + disabled = false, + variant = "default" +}: IconPillButtonProps) => { + return ( + + ); +}; \ No newline at end of file diff --git a/vite/src/components/v2/icons/AutumnIcons.tsx b/vite/src/components/v2/icons/AutumnIcons.tsx new file mode 100644 index 000000000..02fa22421 --- /dev/null +++ b/vite/src/components/v2/icons/AutumnIcons.tsx @@ -0,0 +1,501 @@ +import type { LucideProps } from "lucide-react"; + +// Row 1 Icons +export const CheckCircle = (props: LucideProps) => ( + + + + Check Circle + +); + +export const PlusCircle = (props: LucideProps) => ( + + + + + Plus Circle + +); + +export const XCircle = (props: LucideProps) => ( + + + + + X Circle + +); + +export const Edit = (props: LucideProps) => ( + + + + Edit + +); + +export const Copy = (props: LucideProps) => ( + + + + Copy + +); + +export const Trash = (props: LucideProps) => ( + + + + + + + Trash + +); + +export const GripVertical = (props: LucideProps) => ( + + + + + + + + Grip Vertical + +); + +// Database Icons +export const Database = (props: LucideProps) => ( + + + + + + Database + +); + +export const DatabaseBackup = (props: LucideProps) => ( + + + + + + + Database Backup + +); + +// Row 2 Icons +export const Download = (props: LucideProps) => ( + + + + + + Download + +); + +export const RefreshCw = (props: LucideProps) => ( + + + + + + Refresh Cw + +); + +export const Plus = (props: LucideProps) => ( + + + + Plus + +); + +export const ChevronRight = (props: LucideProps) => ( + + + + Chevron Right + +); + +export const ChevronLeft = (props: LucideProps) => ( + + + + Chevron Left + +); + +export const RotateCcw = (props: LucideProps) => ( + + + + + Rotate Ccw + +); + +export const Clock = (props: LucideProps) => ( + + + + Clock + +); + +export const Play = (props: LucideProps) => ( + + + + Play + +); + +export const Compass = (props: LucideProps) => ( + + + + + + + + + + + + + + + Compass + +); + +// Row 3 Icons continue in next section for better organization... + +export const Sun = (props: LucideProps) => ( + + + + + + + Sun + +); + +export const Expand = (props: LucideProps) => ( + + + + + + + + + + +); + +export const RotateCw = (props: LucideProps) => ( + + + + +); + +export const Calendar = (props: LucideProps) => ( + + + + + + + +); + +export const CreditCard = (props: LucideProps) => ( + + + + + + Credit Card + +); + +export const UserPlus = (props: LucideProps) => ( + + + + + + UserPlus + +); + +export const User = (props: LucideProps) => ( + + + + + User + +); + +export const Shield = (props: LucideProps) => ( + + + + + + Shield + +); + +export const DollarSign = (props: LucideProps) => ( + + + + + + Dollar Sign + +); + +// Row 4 Icons +export const UserCircle = (props: LucideProps) => ( + + + + + +); + +export const ExternalLink = (props: LucideProps) => ( + + + + +); + +export const Key = (props: LucideProps) => ( + + + + +); + +export const Lightbulb = (props: LucideProps) => ( + + + + Lightbulb + +); + +export const FileText = (props: LucideProps) => ( + + + + File Text + +); + +export const Archive = (props: LucideProps) => ( + + + + + + + Archive + +); + +export const Package = (props: LucideProps) => ( + + + + Package + +); + +export const BarChart = (props: LucideProps) => ( + + + + + Bar Chart + +); + +export const Folder = (props: LucideProps) => ( + + + Folder + +); + +// Row 5 Icons +export const Monitor = (props: LucideProps) => ( + + + + + + Monitor + +); + +export const Share = (props: LucideProps) => ( + + + + + Share + +); + +// Additional icons from the updated SVG +export const CheckCircleIcon = (props: LucideProps) => ( + + + + Check Circle + +); + +export const PlusCircleIcon = (props: LucideProps) => ( + + + + + Plus Circle + +); + +export const XCircleIcon = (props: LucideProps) => ( + + + + + X Circle + +); + +export const EditIcon = (props: LucideProps) => ( + + + + Edit + +); + +export const CopyIcon = (props: LucideProps) => ( + + + + Copy + +); + +export const TrashIcon = (props: LucideProps) => ( + + + + + + + Trash + +); + +export const UserPlusIcon = (props: LucideProps) => ( + + + + + + User Plus + +); + +export const PowerIcon = (props: LucideProps) => ( + + + + + + Power + +); + +export const Wifi = (props: LucideProps) => ( + + + + + Wifi + +); + +export const Link = (props: LucideProps) => ( + + + Link + +); + +// Coin icons from Row 2 +export const CoinPlain = (props: LucideProps) => ( + + + + + + Coin Plain + +); + +export const CoinWithCheck = (props: LucideProps) => ( + + + + + + + Coin With Check + +); + +export const CoinWithCheckWhite = (props: LucideProps) => ( + + + + + + + + Coin With Check White + +); + +export const CoinWithX = (props: LucideProps) => ( + + + + + + + + Coin With X + +); \ No newline at end of file diff --git a/vite/src/index.css b/vite/src/index.css index ab33c52dc..a8988028b 100644 --- a/vite/src/index.css +++ b/vite/src/index.css @@ -7,6 +7,7 @@ ---break--- */ @plugin "tailwindcss-animate"; @import "tailwind-scrollbar-hide/v4"; +@import '@squircle/tailwindcss'; /* ---break--- */ @custom-variant dark (&:is(.dark *)); diff --git a/vite/src/views/products/plan/PlanEditorView.tsx b/vite/src/views/products/plan/PlanEditorView.tsx index b9a1e8c75..55ac295e1 100644 --- a/vite/src/views/products/plan/PlanEditorView.tsx +++ b/vite/src/views/products/plan/PlanEditorView.tsx @@ -55,8 +55,8 @@ export default function PlanEditorView() { open={showNewVersionDialog} setOpen={setShowNewVersionDialog} /> -
-
+
+
-
-
+
+
diff --git a/vite/src/views/products/plan/components/Editor.tsx b/vite/src/views/products/plan/components/Editor.tsx index ab02d7faf..24b3d3cab 100644 --- a/vite/src/views/products/plan/components/Editor.tsx +++ b/vite/src/views/products/plan/components/Editor.tsx @@ -1,7 +1,7 @@ import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import { useProductContext } from "@/views/products/product/ProductContext"; import EditorTopSection from "./EditorTopSection"; -import PlanCard from "./PlanCard"; +import PlanCard from "./PlanCard/PlanCard"; export const ManagePlan = ({ hideAdminHover = false, @@ -12,7 +12,7 @@ export const ManagePlan = ({ const { product, entityId } = useProductContext(); return ( -
+
-
+
diff --git a/vite/src/views/products/plan/components/PlanCard.tsx b/vite/src/views/products/plan/components/PlanCard.tsx deleted file mode 100644 index 51466585b..000000000 --- a/vite/src/views/products/plan/components/PlanCard.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { ProductV2 } from "@autumn/shared"; -import { PencilIcon, Trash2 } from "lucide-react"; -import { CopyableSpan } from "@/components/general/CopyablePre"; -import { Card, CardHeader } from "@/components/ui/card"; - -export default function PlanCard({ product }: { product: ProductV2 }) { - return ( - - -
-
- - {product.name} - - -
-
- - -
-
-
-
- ); -} diff --git a/vite/src/views/products/plan/components/PlanCard/PlanCard.tsx b/vite/src/views/products/plan/components/PlanCard/PlanCard.tsx new file mode 100644 index 000000000..52e908a98 --- /dev/null +++ b/vite/src/views/products/plan/components/PlanCard/PlanCard.tsx @@ -0,0 +1,53 @@ +import type { ProductV2 } from "@autumn/shared"; +import { mapToProductV3 } from "@autumn/shared"; +import { CurrencyDollar } from "@phosphor-icons/react"; +import { CopyableSpan } from "@/components/general/CopyablePre"; +import { Card, CardHeader } from "@/components/ui/card"; +import { useEnv } from "@/utils/envUtils"; +import { keyToTitle } from "@/utils/formatUtils/formatTextUtils"; +import { PlanFeatureList } from "./PlanFeatureList"; + +export default function PlanCard({ product }: { product: ProductV2 }) { + const env = useEnv(); + const productV3 = mapToProductV3({ product, env }); + + return ( + + +
+
+ + {product.name} + + +
+
+ + {productV3.description} + + +
+ + + + {productV3.price?.amount ? ( + + ${productV3.price.amount}/ + {keyToTitle(productV3.price.interval ?? "once", { exclusionMap: { one_off: "once" } }).toLowerCase()} + + ) : ( + No price set + )} +
+ +
+
+ ); +} diff --git a/vite/src/views/products/plan/components/PlanCard/PlanCardToolbar.tsx b/vite/src/views/products/plan/components/PlanCard/PlanCardToolbar.tsx new file mode 100644 index 000000000..072224016 --- /dev/null +++ b/vite/src/views/products/plan/components/PlanCard/PlanCardToolbar.tsx @@ -0,0 +1,8 @@ +import { PencilIcon, Trash2 } from "lucide-react" + +export const PlanCardToolbar = () => { + return
+ + +
+} \ No newline at end of file diff --git a/vite/src/views/products/plan/components/PlanCard/PlanFeatureIcon.tsx b/vite/src/views/products/plan/components/PlanCard/PlanFeatureIcon.tsx new file mode 100644 index 000000000..d2cbdca4d --- /dev/null +++ b/vite/src/views/products/plan/components/PlanCard/PlanFeatureIcon.tsx @@ -0,0 +1,93 @@ +import type { ProductItem } from "@autumn/shared"; +import type { Icon } from "@phosphor-icons/react"; +import { + CheckCircle, + ArrowsClockwise, + ArrowClockwise, + Coins, + CurrencyDollar, + XCircle +} from "@phosphor-icons/react"; +import { + ProductItemFeatureType, + UsageModel +} from "@autumn/shared"; + +interface PlanFeatureIconProps { + item: ProductItem; + position: "left" | "right"; +} + +// Helper function to classify feature type +const getFeatureType = (item: ProductItem): ProductItemFeatureType | null => { + return item.feature_type || null; +}; + +// Helper function to classify billing/usage type +const getBillingType = (item: ProductItem): "included" | "prepaid" | "paid" | "none" => { + // Check if it's included/free (no price and no usage model) + if (!item.price && !item.usage_model && !item.price_config) { + return "included"; + } + + // Check for prepaid model + if (item.usage_model === UsageModel.Prepaid) { + return "prepaid"; + } + + // Check for paid model + if (item.usage_model === UsageModel.PayPerUse || item.price || item.price_config) { + return "paid"; + } + + return "none"; +}; + +// Helper function to get the left icon (feature type) +const getLeftIcon = (item: ProductItem): { icon: Icon; color: string } => { + const featureType = getFeatureType(item); + + switch (featureType) { + case ProductItemFeatureType.Boolean: + return { icon: CheckCircle, color: "text-[#DE1779]" }; // On/Off - pink + case ProductItemFeatureType.SingleUse: + return { icon: ArrowsClockwise, color: "text-[#DE1779]" }; // Usage-based - pink + case ProductItemFeatureType.ContinuousUse: + return { icon: ArrowClockwise, color: "text-[#DE1779]" }; // Persistent Usage - pink + case ProductItemFeatureType.Static: + return { icon: CheckCircle, color: "text-[#DE1779]" }; // Static - pink + default: + return { icon: CheckCircle, color: "text-[#DE1779]" }; // Default - pink + } +}; + +// Helper function to get the right icon (billing type) +const getRightIcon = (item: ProductItem): { icon: Icon; color: string } => { + const billingType = getBillingType(item); + + switch (billingType) { + case "included": + return { icon: CheckCircle, color: "text-[#10B981]" }; // Included/Free - green + case "prepaid": + return { icon: Coins, color: "text-[#3B82F6]" }; // Prepaid - blue + case "paid": + return { icon: CurrencyDollar, color: "text-[#F59E0B]" }; // Paid - orange + case "none": + return { icon: XCircle, color: "text-[#6B7280]" }; // None - gray + default: + return { icon: XCircle, color: "text-[#6B7280]" }; // Default - gray + } +}; + +export const PlanFeatureIcon = ({ item, position }: PlanFeatureIconProps) => { + const iconData = position === "left" ? getLeftIcon(item) : getRightIcon(item); + const Icon = iconData.icon; + + return ( + + ); +}; \ No newline at end of file diff --git a/vite/src/views/products/plan/components/PlanCard/PlanFeatureList.tsx b/vite/src/views/products/plan/components/PlanCard/PlanFeatureList.tsx new file mode 100644 index 000000000..3baec46fd --- /dev/null +++ b/vite/src/views/products/plan/components/PlanCard/PlanFeatureList.tsx @@ -0,0 +1,47 @@ +import type { ProductItem } from "@autumn/shared"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { useProductContext } from "@/views/products/product/ProductContext"; +import { PlanFeatureRow } from "./PlanFeatureRow"; +import { isPriceItem } from "@/utils/product/getItemType"; + +export const PlanFeatureList = () => { + const { product } = useProductContext(); + const { features } = useFeaturesQuery(); + + // Filter out standalone price items - only show features and priced features + const filteredItems = product?.items?.filter(item => !isPriceItem(item)) || []; + + if (filteredItems.length === 0) { + return ( +
+ No features configured for this plan +
+ ); + } + + const handleFeatureClick = (item: ProductItem) => { + console.log("Feature clicked:", item); + }; + + const handleDelete = (item: ProductItem) => { + console.log("Delete feature:", item); + // Add your delete logic here + }; + + return ( +
+

Features

+
+ {filteredItems.map((item, index) => ( + + ))} +
+
+ ); +}; diff --git a/vite/src/views/products/plan/components/PlanCard/PlanFeatureRow.tsx b/vite/src/views/products/plan/components/PlanCard/PlanFeatureRow.tsx new file mode 100644 index 000000000..ed018e8b0 --- /dev/null +++ b/vite/src/views/products/plan/components/PlanCard/PlanFeatureRow.tsx @@ -0,0 +1,118 @@ +import type { ProductItem, Feature } from "@autumn/shared"; +import { ProductItemType, getProductItemDisplay } from "@autumn/shared"; +import { CurrencyDollar, DotsSixVertical, Trash } from "@phosphor-icons/react"; +import { Button } from "@/components/ui/button"; +import { useOrg } from "@/hooks/common/useOrg"; +import { getItemType } from "@/utils/product/productItemUtils"; +import { PlanFeatureIcon } from "./PlanFeatureIcon"; + +interface PlanFeatureRowProps { + item: ProductItem; + features: Feature[]; + onRowClick?: (item: ProductItem) => void; + onDelete?: (item: ProductItem) => void; +} + +export const PlanFeatureRow = ({ + item, + features, + onRowClick, + onDelete, +}: PlanFeatureRowProps) => { + const { org } = useOrg(); + + const getDisplayText = (item: ProductItem) => { + const displayData = getProductItemDisplay({ + item, + features, + currency: org?.default_currency || "USD", + }); + + // Combine primary and secondary text + if (displayData.secondary_text) { + return `${displayData.primary_text} ${displayData.secondary_text}`; + } + return displayData.primary_text; + }; + + const itemType = getItemType(item); + const showCoinIcon = + itemType === ProductItemType.Price || + itemType === ProductItemType.FeaturePrice; + + return ( +
+ {/* Left side - Icons and text */} +
+ {/* Icon container */} +
+ {/* First icon */} + + + {/* Arrow/separator */} +
+ + {/* Second icon */} + +
+ + {/* Feature text with optional coin icon */} +
+ {showCoinIcon && ( + + )} + + {getDisplayText(item)} + +
+
+ + {/* Right side - Delete and drag handle */} +
+ {/* Delete button */} + + + {/* 6-dot drag handle */} +
+ +
+
+
+ ); +}; diff --git a/vite/src/views/products/plan/components/RightPanel.tsx b/vite/src/views/products/plan/components/RightPanel.tsx new file mode 100644 index 000000000..811d4f9f1 --- /dev/null +++ b/vite/src/views/products/plan/components/RightPanel.tsx @@ -0,0 +1,115 @@ +import { Minus, Plus } from "lucide-react"; +import { useState } from "react"; +import { SideAccordion } from "@/components/general/SideAccordion"; +import { Accordion } from "@/components/ui/accordion"; +import { isOneOffProduct } from "@/utils/product/priceUtils"; +import { AttachButton } from "@/views/customers/customer/product/components/AttachButton"; +import { CustomerProductBadge } from "@/views/customers/customer/product/components/CustomerProductBadge"; +import { UpdateProductButton } from "../../product/components/UpdateProductButton"; +import { CreateFreeTrial } from "../../product/free-trial/CreateFreeTrial"; +import { FreeTrialView } from "../../product/free-trial/FreeTrialView"; +import { useProductContext } from "../../product/ProductContext"; +import { ProductProps } from "../../product/ProductProps"; +import { ProductVersions } from "../../product/ProductVersions"; +import { EntitiesSidebar } from "../../product/product-item/EntitiesSidebar"; + +export default function ProductSidebar() { + const { product, setProduct, isCusProductView } = useProductContext(); + const [freeTrialModalOpen, setFreeTrialModalOpen] = useState(false); + const [entitiesOpen, setEntitiesOpen] = useState(false); + const [accordionValues, setAccordionValues] = useState([ + "properties", + "versions", + "free-trial", + "entities", + ]); + + const handleFreeTrialModalOpen = () => { + setFreeTrialModalOpen(!freeTrialModalOpen); + }; + const handleDeleteFreeTrial = async () => { + setProduct({ ...product, free_trial: null }); + }; + + const handleAccordionToggle = (value: string) => { + setAccordionValues((prev) => + prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value], + ); + }; + + return ( +
+
+ {isCusProductView && } +
+ + +
+ + + +
+
+ + + +
+
+ setEntitiesOpen(!entitiesOpen)} + buttonIcon={} + > + + +
+ + + +
+ : + } + disabledReason={ + isOneOffProduct(product.items, product.is_add_on) + ? "Can't add a free trial to an a one time product" + : undefined + } + > +
+ {product.free_trial ? ( + + ) : ( + + Add a free trial to this product. + + )} +
+
+
+
+ {isCusProductView ? : } +
+
+
+ ); +}