From 4b79dd1a4ae84f9befad76a7447f8ef452a1defb Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 20 Aug 2025 16:50:45 -0700 Subject: [PATCH 1/4] offer and item pages, edits, deletes --- .../payments/items/page-client.tsx | 37 ++++ .../[projectId]/payments/items/page.tsx | 23 +++ .../payments/offers/page-client.tsx | 35 ++++ .../[projectId]/payments/offers/page.tsx | 23 +++ .../[projectId]/payments/page-client.tsx | 165 +----------------- .../projects/[projectId]/sidebar-layout.tsx | 17 ++ .../data-table/payment-item-table.tsx | 64 +++++-- .../data-table/payment-offer-table.tsx | 74 ++++++-- .../src/components/dialog-opener.tsx | 27 +++ .../day-interval-selector-field.tsx | 6 +- .../payments/included-item-editor.tsx | 8 +- .../src/components/payments/item-dialog.tsx | 102 +++++++++++ .../src/components/payments/offer-dialog.tsx | 119 +++++++++++++ .../src/components/payments/price-editor.tsx | 4 +- packages/stack-shared/src/config/schema.ts | 2 +- packages/stack-shared/src/schema-fields.ts | 2 +- 16 files changed, 509 insertions(+), 199 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx create mode 100644 apps/dashboard/src/components/dialog-opener.tsx create mode 100644 apps/dashboard/src/components/payments/item-dialog.tsx create mode 100644 apps/dashboard/src/components/payments/offer-dialog.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx new file mode 100644 index 0000000000..0e4480c6e6 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page-client.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { PaymentItemTable } from "@/components/data-table/payment-item-table"; +import { ItemDialog } from "@/components/payments/item-dialog"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; +import { DialogOpener } from "@/components/dialog-opener"; + + +export default function PageClient() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const config = project.useConfig(); + const paymentsConfig = config.payments; + + return ( + + {state => ( + + )} + + } + > + + + ); +} + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx new file mode 100644 index 0000000000..9bb59067a1 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/items/page.tsx @@ -0,0 +1,23 @@ +import { devFeaturesEnabledForProject } from "@/lib/utils"; +import { notFound } from "next/navigation"; +import PageClient from "./page-client"; + +export const metadata = { + title: "Items", +}; + +type Params = { + projectId: string, +}; + +export default async function Page({ params }: { params: Promise }) { + const { projectId } = await params; + if (!devFeaturesEnabledForProject(projectId)) { + notFound(); + } + return ( + + ); +} + + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx new file mode 100644 index 0000000000..5e134671d4 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page-client.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { PaymentOfferTable } from "@/components/data-table/payment-offer-table"; +import { OfferDialog } from "@/components/payments/offer-dialog"; +import { PageLayout } from "../../page-layout"; +import { useAdminApp } from "../../use-admin-app"; +import { DialogOpener } from "@/components/dialog-opener"; + +export default function PageClient() { + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + const config = project.useConfig(); + const paymentsConfig = config.payments; + + return ( + + {state => ( + + )} + } + > + + + ); +} + + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx new file mode 100644 index 0000000000..ec3ea24924 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/offers/page.tsx @@ -0,0 +1,23 @@ +import { devFeaturesEnabledForProject } from "@/lib/utils"; +import { notFound } from "next/navigation"; +import PageClient from "./page-client"; + +export const metadata = { + title: "Offers", +}; + +type Params = { + projectId: string, +}; + +export default async function Page({ params }: { params: Promise }) { + const { projectId } = await params; + if (!devFeaturesEnabledForProject(projectId)) { + notFound(); + } + return ( + + ); +} + + diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx index ed97f06ee1..3de3375b2c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/page-client.tsx @@ -1,17 +1,7 @@ "use client"; -import { PaymentItemTable } from "@/components/data-table/payment-item-table"; -import { PaymentOfferTable } from "@/components/data-table/payment-offer-table"; -import { FormDialog, SmartFormDialog } from "@/components/form-dialog"; -import { InputField, SelectField, SwitchField } from "@/components/form-fields"; -import { IncludedItemEditorField } from "@/components/payments/included-item-editor"; -import { PriceEditorField } from "@/components/payments/price-editor"; -import { AdminProject } from "@stackframe/stack"; -import { - offerPriceSchema, - userSpecifiedIdSchema, - yupRecord -} from "@stackframe/stack-shared/dist/schema-fields"; +import { SmartFormDialog } from "@/components/form-dialog"; +import { SelectField } from "@/components/form-fields"; import { wait } from "@stackframe/stack-shared/dist/utils/promises"; import { Button, @@ -20,8 +10,8 @@ import { Typography, toast } from "@stackframe/stack-ui"; +import { ConnectPayments } from "@stripe/react-connect-js"; import { ArrowRight, BarChart3, Repeat, Shield, Wallet, Webhook } from "lucide-react"; -import { useState } from "react"; import * as yup from "yup"; import { PageLayout } from "../page-layout"; import { useAdminApp } from "../use-admin-app"; @@ -33,9 +23,6 @@ export default function PageClient() { const stripeAccountId = config.payments.stripeAccountId; const paymentsConfig = config.payments; - const [isCreateOfferOpen, setIsCreateOfferOpen] = useState(false); - const [isCreateItemOpen, setIsCreateItemOpen] = useState(false); - const setupPayments = async () => { const { url } = await stackAdminApp.setupPayments(); window.location.href = url; @@ -91,24 +78,11 @@ export default function PageClient() { )} } > - } - /> - } - /> - - +
+
+ +
+
); } @@ -152,126 +126,3 @@ function SetupPaymentsButton({ setupPayments }: { setupPayments: () => Promise ); } - - -function CreateOfferDialog({ - open, - onOpenChange, - project, -}: { - open: boolean, - project: AdminProject, - onOpenChange: (open: boolean) => void, -}) { - const config = project.useConfig(); - - const offerSchema = yup.object({ - offerId: yup.string().defined().label("Offer ID"), - displayName: yup.string().defined().label("Display Name"), - customerType: yup.string().oneOf(["user", "team", "custom"]).defined().label("Customer Type"), - prices: yupRecord(userSpecifiedIdSchema("priceId"), offerPriceSchema).defined().label("Prices").test("at-least-one-price", "At least one price is required", (value) => { - return Object.keys(value).length > 0; - }), - includedItems: yupRecord(userSpecifiedIdSchema("itemId"), yup.object({ - quantity: yup.number().defined(), - repeat: yup.mixed().optional(), - expires: yup.string().oneOf(["never", "when-purchase-expires", "when-repeated"]).optional(), - })).default({}).label("Included Items"), - freeTrialDays: yup.number().min(0).optional().label("Free Trial (days)"), - serverOnly: yup.boolean().default(false).label("Server Only"), - stackable: yup.boolean().default(false).label("Stackable"), - }); - - return ( - ) => { - await project.updateConfig({ - [`payments.offers.${values.offerId}`]: { - prices: values.prices, - includedItems: values.includedItems, - customerType: values.customerType, - displayName: values.displayName, - serverOnly: values.serverOnly, - stackable: values.stackable, - freeTrial: values.freeTrialDays ? [values.freeTrialDays, "day"] : undefined, - }, - }); - }} - render={(form) => ( -
- - - - - - - - {/* */} - - -
- )} - /> - ); -} - - -function CreateItemDialog({ open, onOpenChange, project }: { open: boolean, onOpenChange: (open: boolean) => void, project: AdminProject }) { - const itemSchema = yup.object({ - itemId: yup.string().defined().label("Item ID"), - displayName: yup.string().optional().label("Display Name"), - customerType: yup.string().oneOf(["user", "team", "custom"]).defined().label("Customer Type").meta({ - stackFormFieldRender: (props) => ( - - ), - }), - defaultQuantity: yup.number().min(0).default(0).label("Default Quantity"), - defaultRepeatDays: yup.number().min(1).optional().label("Default Repeat (days)"), - defaultExpires: yup.string().oneOf(["never", "when-repeated"]).optional().label("Default Expires").meta({ - stackFormFieldRender: (props) => ( - - ), - }), - }); - - return ( - { - await project.updateConfig({ - [`payments.items.${values.itemId}`]: { - displayName: values.displayName, - customerType: values.customerType, - default: { - quantity: values.defaultQuantity, - repeat: values.defaultRepeatDays ? [values.defaultRepeatDays, "day"] : undefined, - expires: values.defaultExpires, - }, - }, - }); - }} - /> - ); -} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 8374ab6cf6..a62cc598bf 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -24,6 +24,7 @@ import { } from "@stackframe/stack-ui"; import { Book, + Box, CreditCard, Globe, KeyRound, @@ -226,6 +227,22 @@ const navigationItems: (Label | Item | Hidden)[] = [ type: 'item', requiresDevFeatureFlag: true, }, + { + name: "Offers", + href: "/payments/offers", + regex: /^\/projects\/[^\/]+\/payments\/offers$/, + icon: SquarePen, + type: 'item', + requiresDevFeatureFlag: true, + }, + { + name: "Items", + href: "/payments/items", + regex: /^\/projects\/[^\/]+\/payments\/items$/, + icon: Box, + type: 'item', + requiresDevFeatureFlag: true, + }, { name: "Configuration", type: 'label' diff --git a/apps/dashboard/src/components/data-table/payment-item-table.tsx b/apps/dashboard/src/components/data-table/payment-item-table.tsx index 5356fccd58..06715b8a65 100644 --- a/apps/dashboard/src/components/data-table/payment-item-table.tsx +++ b/apps/dashboard/src/components/data-table/payment-item-table.tsx @@ -1,14 +1,15 @@ 'use client'; import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; import { SmartFormDialog } from "@/components/form-dialog"; +import { ItemDialog } from "@/components/payments/item-dialog"; import { KnownErrors } from "@stackframe/stack-shared"; import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; +import { has } from "@stackframe/stack-shared/dist/utils/objects"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; -import { ActionCell, DataTable, DataTableColumnHeader, TextCell, toast } from "@stackframe/stack-ui"; +import { ActionCell, ActionDialog, DataTable, DataTableColumnHeader, TextCell, toast } from "@stackframe/stack-ui"; import { ColumnDef } from "@tanstack/react-table"; import { useState } from "react"; import * as yup from "yup"; -import { SelectField } from "../form-fields"; type PaymentItem = { id: string, @@ -59,13 +60,7 @@ const columns: ColumnDef[] = [ } ]; -export function PaymentItemTable({ - items, - toolbarRender, -}: { - items: Record["items"][string]>, - toolbarRender: () => React.ReactNode, -}) { +export function PaymentItemTable({ items }: { items: Record["items"][string]> }) { const data: PaymentItem[] = Object.entries(items).map(([id, item]) => ({ id, ...item, @@ -77,24 +72,32 @@ export function PaymentItemTable({ defaultColumnFilters={[]} defaultSorting={[]} showDefaultToolbar={false} - toolbarRender={toolbarRender} />; } function ActionsCell({ item }: { item: PaymentItem }) { const [open, setOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); return ( <> setOpen(true), }, + { + item: "Edit", + onClick: () => setIsEditOpen(true), + }, + '-', { item: "Delete", - disabled: true, - onClick: () => { }, + onClick: () => setIsDeleteOpen(true), + danger: true, }, ]} /> @@ -104,6 +107,41 @@ function ActionsCell({ item }: { item: PaymentItem }) { itemId={item.id} customerType={item.customerType} /> + + { + const config = await project.getConfig(); + for (const [offerId, offer] of Object.entries(config.payments.offers)) { + if (has(offer.includedItems, item.id)) { + toast({ + title: "Item is included in offer", + description: `Please remove it from the offer "${offerId}" before deleting.`, + variant: "destructive", + }); + return "prevent-close"; + } + } + await project.updateConfig({ + [`payments.items.${item.id}`]: null, + }); + toast({ title: "Item deleted" }); + } + }} + /> ); } diff --git a/apps/dashboard/src/components/data-table/payment-offer-table.tsx b/apps/dashboard/src/components/data-table/payment-offer-table.tsx index 96e4cf180a..3e3f4c8b40 100644 --- a/apps/dashboard/src/components/data-table/payment-offer-table.tsx +++ b/apps/dashboard/src/components/data-table/payment-offer-table.tsx @@ -1,8 +1,11 @@ 'use client'; -import { ActionCell, Button, DataTable, DataTableColumnHeader, TextCell } from "@stackframe/stack-ui"; +import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; +import { OfferDialog } from "@/components/payments/offer-dialog"; +import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; +import { ActionCell, ActionDialog, DataTable, DataTableColumnHeader, TextCell, toast } from "@stackframe/stack-ui"; import { ColumnDef } from "@tanstack/react-table"; +import { useState } from "react"; import * as yup from "yup"; -import { branchPaymentsSchema } from "@stackframe/stack-shared/dist/config/schema"; type PaymentOffer = { id: string, @@ -41,25 +44,11 @@ const columns: ColumnDef[] = [ }, { id: "actions", - cell: ({ row }) => { }, - }, - ]} - />, + cell: ({ row }) => , } ]; -export function PaymentOfferTable({ - offers, - toolbarRender, -}: { - offers: Record["offers"][string]>, - toolbarRender: () => React.ReactNode, -}) { +export function PaymentOfferTable({ offers }: { offers: Record["offers"][string]> }) { const data: PaymentOffer[] = Object.entries(offers).map(([id, offer]) => ({ id, ...offer, @@ -71,6 +60,53 @@ export function PaymentOfferTable({ defaultColumnFilters={[]} defaultSorting={[]} showDefaultToolbar={false} - toolbarRender={toolbarRender} />; } + +function ActionsCell({ offer }: { offer: PaymentOffer }) { + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const stackAdminApp = useAdminApp(); + const project = stackAdminApp.useProject(); + + return ( + <> + setIsEditOpen(true), + }, + '-', + { + item: "Delete", + onClick: () => setIsDeleteOpen(true), + danger: true, + }, + ]} + /> + + { + await project.updateConfig({ [`payments.offers.${offer.id}`]: null }); + toast({ title: "Offer deleted" }); + }, + }} + /> + + ); +} diff --git a/apps/dashboard/src/components/dialog-opener.tsx b/apps/dashboard/src/components/dialog-opener.tsx new file mode 100644 index 0000000000..c754972d92 --- /dev/null +++ b/apps/dashboard/src/components/dialog-opener.tsx @@ -0,0 +1,27 @@ +import React, { useState, ReactNode } from 'react'; +import { Button } from "@stackframe/stack-ui"; + +type DialogState = { + isOpen: boolean, + setIsOpen: (open: boolean) => void, +} + +type DialogOpenerProps = { + triggerLabel?: string, + children: (state: DialogState) => ReactNode, +} + +export function DialogOpener({ triggerLabel, children }: DialogOpenerProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + {triggerLabel && ( + + )} + {children({ isOpen, setIsOpen })} + + ); +}; diff --git a/apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx b/apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx index 8ada9a426e..54ac5fb9c9 100644 --- a/apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx +++ b/apps/dashboard/src/components/form-fields/day-interval-selector-field.tsx @@ -15,6 +15,7 @@ export function DayIntervalSelectorField(props: { label: React.ReactNode, required?: boolean, includeNever?: boolean, + unsetLabel?: string, }) { const convertToDayInterval = (value: string): DayInterval | undefined => { @@ -40,13 +41,14 @@ export function DayIntervalSelectorField(props: {