diff --git a/bun.lock b/bun.lock index 17a5e8939..98e1cfb25 100644 --- a/bun.lock +++ b/bun.lock @@ -108,7 +108,7 @@ "@types/express": "^5.0.3", "@types/lodash-es": "^4.17.12", "@types/mocha": "^10.0.10", - "@types/node": "^22.13.4", + "@types/node": "^24.9.1", "@types/pg": "^8.11.10", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", @@ -2942,7 +2942,9 @@ "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@autumn/server/@types/node": ["@types/node@22.18.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ=="], + "@autumn/server/@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], + + "@autumn/shared/@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], "@autumn/shared/stripe": ["stripe@18.5.0", "", { "dependencies": { "qs": "^6.11.0" }, "peerDependencies": { "@types/node": ">=12.x.x" }, "optionalPeers": ["@types/node"] }, "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA=="], @@ -3498,7 +3500,9 @@ "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], - "@autumn/server/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "@autumn/server/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "@autumn/shared/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "@autumn/vite/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], diff --git a/server/package.json b/server/package.json index 77d3c5702..c5e8803e3 100644 --- a/server/package.json +++ b/server/package.json @@ -109,7 +109,7 @@ "@types/express": "^5.0.3", "@types/lodash-es": "^4.17.12", "@types/mocha": "^10.0.10", - "@types/node": "^22.13.4", + "@types/node": "^24.9.1", "@types/pg": "^8.11.10", "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts index 5a66f69ec..5df974d7a 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleGetOAuthUrl.ts @@ -23,7 +23,11 @@ export const handleGetOAuthUrl = createRoute({ // Generate OAuth state and store in Redis const frontendUrl = process.env.CLIENT_URL || "http://localhost:5173"; - const redirectUri = `${frontendUrl}/dev?tab=stripe`; + // Determine redirect URI based on context + const fromOnboarding = c.req.query("from_onboarding") === "true"; + const redirectUri = fromOnboarding + ? `${frontendUrl}/sandbox/onboarding3?step=playground&m=p` + : `${frontendUrl}/dev?tab=stripe`; const stateKey = await generateOAuthState({ organizationSlug: org.slug, diff --git a/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts index fd55f9603..225cc2b5d 100644 --- a/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts +++ b/server/src/internal/orgs/handlers/stripeHandlers/handleOAuthCallback.ts @@ -54,14 +54,8 @@ export const handleOAuthCallback = async (c: Context) => { const env = envStr === "live" ? AppEnv.Live : AppEnv.Sandbox; const isPlatformFlow = master_org_id !== null; - // Use custom redirect URI if provided (platform flow) - if (isPlatformFlow) { - redirectUrl = new URL(redirect_uri); - } else { - redirectUrl = new URL( - `${frontendUrl}${env === AppEnv.Sandbox ? "/sandbox" : ""}/dev?tab=stripe`, - ); - } + // Use redirect URI from state (supports both platform and standard flows) + redirectUrl = new URL(redirect_uri); // Fetch the organization by slug const org = await OrgService.getBySlug({ db, slug: organization_slug }); diff --git a/vite/src/components/autumn/pricing-table-preview.tsx b/vite/src/components/autumn/pricing-table-preview.tsx index 3b7acd421..f6660cd22 100644 --- a/vite/src/components/autumn/pricing-table-preview.tsx +++ b/vite/src/components/autumn/pricing-table-preview.tsx @@ -28,13 +28,19 @@ export default function PricingTablePreview({ } const handleSubscribe = async (product: ProductV2) => { + // Check if Stripe is connected + if (!org?.stripe_connected) { + setConnectStripeOpen(true); + return; + } + if (product.id) { try { await checkout({ productId: product.id, dialog: OnboardingCheckoutDialog, openInNewTab: true, - successUrl: `${window.location.origin}/sandbox/onboarding3`, + successUrl: `${window.location.origin}/sandbox/onboarding?step=playground&m=p`, }); } catch (error) { console.error("Checkout error:", error); diff --git a/vite/src/index.css b/vite/src/index.css index 243116f43..45af1cabd 100644 --- a/vite/src/index.css +++ b/vite/src/index.css @@ -63,6 +63,7 @@ html { --t10: #d1d1d1; --t11: #e3e3e3; --t12: #444444; + --t13: #FAFAF9; --radius: 0.375rem; /* 6px */ --primary: #8838ff; @@ -72,6 +73,7 @@ html { --radio-group-hover-primary: #fcfaff; --border: #ddd; + --border-sheet: #EAEAE9 --card-border: #d1d1d1; --input: #dddddd; @@ -181,6 +183,7 @@ html { --color-t10: var(--t10); --color-t11: var(--t11); --color-t12: var(--t12); + --color-t13: var(--t13); --color-input: var(--input); --color-icon1: var(--icon1); diff --git a/vite/src/views/onboarding3/ConnectStripeDialog.tsx b/vite/src/views/onboarding3/ConnectStripeDialog.tsx index 424a17a62..0ac3eedc2 100644 --- a/vite/src/views/onboarding3/ConnectStripeDialog.tsx +++ b/vite/src/views/onboarding3/ConnectStripeDialog.tsx @@ -37,7 +37,7 @@ export default function ConnectStripeDialog({ const handleRedirectToOAuth = async () => { try { const { data } = await axiosInstance.get( - `/v1/organization/stripe/oauth_url`, + `/v1/organization/stripe/oauth_url?from_onboarding=true`, ); window.open(data.oauth_url, "_blank"); } catch (error) { diff --git a/vite/src/views/onboarding3/OnboardingView3.tsx b/vite/src/views/onboarding3/OnboardingView3.tsx index f456b2cf8..8ed19735a 100644 --- a/vite/src/views/onboarding3/OnboardingView3.tsx +++ b/vite/src/views/onboarding3/OnboardingView3.tsx @@ -20,6 +20,7 @@ import { useOnboarding3QueryState } from "./hooks/useOnboarding3QueryState"; import { useOnboardingFeatureSync } from "./hooks/useOnboardingFeatureSync"; import { useOnboardingLogic } from "./hooks/useOnboardingLogic"; import { useOnboardingProductSync } from "./hooks/useOnboardingProductSync"; +import { useSyncPlaygroundMode } from "./hooks/useSyncPlaygroundMode"; import { OnboardingPreview } from "./OnboardingPreview"; import { OnboardingStep } from "./utils/onboardingUtils"; @@ -53,6 +54,9 @@ export default function OnboardingContent() { // Initialize onboarding logic and store handlers useOnboardingLogic(); + // Sync playground mode with query params + useSyncPlaygroundMode(); + // Track sign-up event on first mount useEffect(() => { trackSignUp(); @@ -82,7 +86,7 @@ export default function OnboardingContent() { className={cn( "relative w-full h-full flex bg-gray-medium [scrollbar-gutter:stable]", step === OnboardingStep.Integration - ? "overflow-y-auto" + ? "overflow-y-auto bg-t13" : "overflow-y-hidden", )} > diff --git a/vite/src/views/onboarding3/components/playground-step/PlaygroundToolbar.tsx b/vite/src/views/onboarding3/components/playground-step/PlaygroundToolbar.tsx index 4d70fd421..11acdcd23 100644 --- a/vite/src/views/onboarding3/components/playground-step/PlaygroundToolbar.tsx +++ b/vite/src/views/onboarding3/components/playground-step/PlaygroundToolbar.tsx @@ -13,6 +13,7 @@ import { import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useProductStore } from "@/hooks/stores/useProductStore"; import CreatePlanDialog from "@/views/products/products/components/CreatePlanDialog"; +import { useOnboarding3QueryState } from "../../hooks/useOnboarding3QueryState"; import { useOnboardingStore } from "../../store/useOnboardingStore"; export const PlaygroundToolbar = () => { @@ -22,7 +23,9 @@ export const PlaygroundToolbar = () => { // Get current product and playground mode from stores const product = useProductStore((s) => s.product); const playgroundMode = useOnboardingStore((s) => s.playgroundMode); - const setPlaygroundMode = useOnboardingStore((s) => s.setPlaygroundMode); + + // Get query state setters to update URL + const { setQueryStates } = useOnboarding3QueryState(); // Get handlers from store const handlePlanSelect = useOnboardingStore((s) => s.handlePlanSelect); @@ -31,7 +34,10 @@ export const PlaygroundToolbar = () => {
setPlaygroundMode(val as "edit" | "preview")} + onValueChange={(val) => { + // Update query param, which will sync to store + setQueryStates({ m: val === "edit" ? "e" : "p" }); + }} options={[ { value: "edit", diff --git a/vite/src/views/onboarding3/hooks/useOnboarding3QueryState.tsx b/vite/src/views/onboarding3/hooks/useOnboarding3QueryState.tsx index d2b064a72..ce3f8f06a 100644 --- a/vite/src/views/onboarding3/hooks/useOnboarding3QueryState.tsx +++ b/vite/src/views/onboarding3/hooks/useOnboarding3QueryState.tsx @@ -12,6 +12,7 @@ export const useOnboarding3QueryState = () => { OnboardingStep.Integration, ] as const).withDefault(OnboardingStep.PlanDetails), product_id: parseAsString, + m: parseAsStringLiteral(["e", "p"] as const), }, { history: "push", diff --git a/vite/src/views/onboarding3/hooks/useSyncPlaygroundMode.tsx b/vite/src/views/onboarding3/hooks/useSyncPlaygroundMode.tsx new file mode 100644 index 000000000..0abca8a84 --- /dev/null +++ b/vite/src/views/onboarding3/hooks/useSyncPlaygroundMode.tsx @@ -0,0 +1,32 @@ +import { useEffect } from "react"; +import { useOnboardingStore } from "../store/useOnboardingStore"; +import { OnboardingStep } from "../utils/onboardingUtils"; +import { useOnboarding3QueryState } from "./useOnboarding3QueryState"; + +/** + * One-way sync: query param → store + * Query param is the source of truth + */ +export const useSyncPlaygroundMode = () => { + const { queryStates, setQueryStates } = useOnboarding3QueryState(); + const setPlaygroundMode = useOnboardingStore((s) => s.setPlaygroundMode); + + const isPlaygroundStep = queryStates.step === OnboardingStep.Playground; + + // ONE-WAY sync: query param → store (only in playground step) + useEffect(() => { + if (isPlaygroundStep) { + // Initialize m param if not present + if (!queryStates.m) { + setQueryStates({ m: "e" }); + } else if (queryStates.m === "e") { + setPlaygroundMode("edit"); + } else if (queryStates.m === "p") { + setPlaygroundMode("preview"); + } + } else if (queryStates.m !== null) { + // Remove 'm' param when not in playground step + setQueryStates({ m: null }); + } + }, [queryStates.m, queryStates.step, setPlaygroundMode, setQueryStates, isPlaygroundStep]); +}; diff --git a/vite/src/views/products/plan/ProductSheets.tsx b/vite/src/views/products/plan/ProductSheets.tsx index 9463b03af..9edfd6d12 100644 --- a/vite/src/views/products/plan/ProductSheets.tsx +++ b/vite/src/views/products/plan/ProductSheets.tsx @@ -5,8 +5,8 @@ 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"; @@ -73,7 +73,7 @@ export const ProductSheets = () => { }; return ( - + {renderSheet()} ); diff --git a/vite/src/views/products/plan/components/SelectFeatureSheet.tsx b/vite/src/views/products/plan/components/SelectFeatureSheet.tsx index a30f72e68..af7af712a 100644 --- a/vite/src/views/products/plan/components/SelectFeatureSheet.tsx +++ b/vite/src/views/products/plan/components/SelectFeatureSheet.tsx @@ -29,7 +29,15 @@ export function SelectFeatureSheet({ const setProduct = useProductStore((s) => s.setProduct); const setSheet = useSheetStore((s) => s.setSheet); - const filteredFeatures = features.filter((f: Feature) => !f.archived); + // Get feature IDs that are already added to the plan + const addedFeatureIds = new Set( + product.items?.map((item) => item.feature_id).filter(Boolean) || [] + ); + + // Filter out archived features and features already on the plan + const filteredFeatures = features.filter( + (f: Feature) => !f.archived && !addedFeatureIds.has(f.id) + ); const handleFeatureSelect = (featureId: string) => { if (!featureId || !product) return; diff --git a/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx b/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx index 6239786e6..fcf33e26f 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx @@ -24,6 +24,14 @@ export function BillingType() { // Derive billing type from item state const isFeaturePrice = isFeaturePriceItem(item); + // Determine if we should preselect based on explicit configuration + const hasExplicitConfig = + isFeaturePrice || // Has tiers, so it's priced + (item.included_usage !== undefined && item.included_usage !== null) || // Has explicit included usage + item.usage_model !== undefined; // Has explicit usage model + + const shouldPreselect = hasExplicitConfig; + const setBillingType = (type: "included" | "priced") => { const getPricedInterval = () => { if ( @@ -87,7 +95,7 @@ export function BillingType() {
setBillingType("included")} icon={} /> @@ -105,7 +113,7 @@ export function BillingType() {
setBillingType("priced")} icon={} /> diff --git a/vite/src/views/products/plan/components/plan-card/AddFeatureRow.tsx b/vite/src/views/products/plan/components/plan-card/AddFeatureRow.tsx index 3a06cbb73..1beeb6e4a 100644 --- a/vite/src/views/products/plan/components/plan-card/AddFeatureRow.tsx +++ b/vite/src/views/products/plan/components/plan-card/AddFeatureRow.tsx @@ -2,6 +2,7 @@ import { PlusIcon } from "@phosphor-icons/react"; import { Button } from "@/components/v2/buttons/Button"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useSheetStore } from "@/hooks/stores/useSheetStore"; +import { useProductStore } from "@/hooks/stores/useProductStore"; interface AddFeatureRowProps { disabled?: boolean; @@ -11,13 +12,25 @@ interface AddFeatureRowProps { export const AddFeatureRow = ({ disabled }: AddFeatureRowProps) => { const { features } = useFeaturesQuery(); const setSheet = useSheetStore((s) => s.setSheet); + const product = useProductStore((s) => s.product); const handleAddFeatureClick = () => { - if (features.length === 0) { - // No features exist, go directly to create flow + // Get feature IDs that are already added to the plan + const addedFeatureIds = new Set( + product.items?.map((item) => item.feature_id).filter(Boolean) || [] + ); + + // Filter out features that are already on the plan + const availableFeatures = features.filter( + (feature) => !addedFeatureIds.has(feature.id) + ); + + if (availableFeatures.length === 0) { + // No features available to add (either none exist or all are already added) + // Go directly to create flow setSheet({ type: "new-feature", itemId: "new" }); } else { - // Features exist, open select sheet + // Features available to add, open select sheet setSheet({ type: "select-feature", itemId: "select" }); } }; diff --git a/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx b/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx index cfc425e21..fd5e9a588 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx @@ -167,7 +167,7 @@ export const PlanFeatureRow = ({ size="sm" variant="skeleton" tabIndex={-1} - className="absolute right-0 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-50 bg-hover-primary" + className="absolute right-0 z-20 opacity-0 group-hover:opacity-100 transition-opacity duration-50 bg-hover-primary group-hover:bg-transparent" />