From 9e074ead99dae32debebafdbcaf33eb32f04a935 Mon Sep 17 00:00:00 2001 From: Ayush Rodrigues Date: Mon, 2 Feb 2026 14:28:11 +0000 Subject: [PATCH] added ai chat for existing pricing --- .cursor/debug.log | 192 +++++++++ .../handlers/handleSyncPreviewPricing.ts | 45 +- .../misc/pricingAgent/pricingAgentRouter.ts | 256 ++---------- .../misc/pricingAgent/pricingAgentSchemas.ts | 262 ++++++++++++ shared/index.ts | 2 + shared/utils/agentTypes.ts | 311 ++++++++++++++ shared/utils/productDisplayUtils.ts | 6 +- .../ai-elements/CompactPromptInput.tsx | 64 +++ .../general/table/table-row-cells.tsx | 2 +- .../components/table/EmptyState.tsx | 2 +- vite/src/views/onboarding4/AIChatView.tsx | 394 ++---------------- .../src/views/onboarding4/CopyPlansButton.tsx | 2 +- .../src/views/onboarding4/OnboardingGuide.tsx | 6 +- .../views/onboarding4/PricingConfigSheet.tsx | 2 +- vite/src/views/onboarding4/PricingPreview.tsx | 47 ++- .../components/ChatInputComponents.tsx | 71 ++++ .../components/PricingChatPanel.tsx | 169 ++++++++ .../onboarding4/hooks/usePricingAgentChat.ts | 244 +++++++++++ .../onboarding4/preview/GroupedPlanCards.tsx | 49 +++ .../preview/PreviewCheckoutButton.tsx | 7 +- .../preview/PreviewCreditSchemaCard.tsx | 13 +- .../preview/PreviewFeatureIcon.tsx | 53 +-- .../onboarding4/preview/PreviewFeatureRow.tsx | 14 +- .../onboarding4/preview/PreviewPlanCard.tsx | 13 +- .../onboarding4/preview/PreviewPlanHeader.tsx | 2 +- .../views/onboarding4/preview/previewTypes.ts | 356 ++++++++++++---- .../views/onboarding4/pricingAgentUtils.ts | 137 ------ .../onboarding4/utils/convertToAgentConfig.ts | 5 + .../edit-plan-details/BasePriceSection.tsx | 248 ++++++----- .../products/product/utils/updateProduct.ts | 4 +- .../views/products/products/ProductsPage.tsx | 21 +- .../components/ConfirmBatchVersionDialog.tsx | 97 +++++ .../components/ProductsAIChatView.tsx | 150 +++++++ .../components/ProductsPageHeader.tsx | 24 ++ .../components/ProductsViewToggle.tsx | 66 +++ .../product-list/ProductListTable.tsx | 117 ++---- .../products/hooks/usePricingConfigSave.ts | 147 +++++++ 37 files changed, 2500 insertions(+), 1100 deletions(-) create mode 100644 .cursor/debug.log create mode 100644 server/src/internal/misc/pricingAgent/pricingAgentSchemas.ts create mode 100644 shared/utils/agentTypes.ts create mode 100644 vite/src/components/ai-elements/CompactPromptInput.tsx create mode 100644 vite/src/views/onboarding4/components/ChatInputComponents.tsx create mode 100644 vite/src/views/onboarding4/components/PricingChatPanel.tsx create mode 100644 vite/src/views/onboarding4/hooks/usePricingAgentChat.ts create mode 100644 vite/src/views/onboarding4/preview/GroupedPlanCards.tsx delete mode 100644 vite/src/views/onboarding4/pricingAgentUtils.ts create mode 100644 vite/src/views/onboarding4/utils/convertToAgentConfig.ts create mode 100644 vite/src/views/products/products/components/ConfirmBatchVersionDialog.tsx create mode 100644 vite/src/views/products/products/components/ProductsAIChatView.tsx create mode 100644 vite/src/views/products/products/components/ProductsPageHeader.tsx create mode 100644 vite/src/views/products/products/components/ProductsViewToggle.tsx create mode 100644 vite/src/views/products/products/hooks/usePricingConfigSave.ts diff --git a/.cursor/debug.log b/.cursor/debug.log new file mode 100644 index 000000000..07d1c1e0a --- /dev/null +++ b/.cursor/debug.log @@ -0,0 +1,192 @@ +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038199263,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199264,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199265,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038199265,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199266,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038199267,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038199268,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038199268,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038199268,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038199268,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203281,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203282,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038203283,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203283,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203283,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203283,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038203283,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203290,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203291,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038203292,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038203292,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038203292,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038203292,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038203292,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038212315,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038212315,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212315,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038212315,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212316,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038212317,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212322,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212323,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212323,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038212323,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212323,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038212323,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"energy","f2_id":"energy","f1_name":"Energy","f2_name":"Energy","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":true,"d2_exists":true},"timestamp":1770038212324,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"energy","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"premium_messages","f2_id":"premium_messages","f1_name":"Premium Messages","f2_name":"Premium Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"premium_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"credits","f2_id":"credits","f1_name":"Credits","f2_name":"Credits","f1_type":"credit_system","f2_type":"credit_system"},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"credits","schemaEqual":true,"cs1":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}],"cs2":[{"metered_feature_id":"standard_messages","credit_cost":1},{"metered_feature_id":"premium_messages","credit_cost":10}]},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:areFeaturesEqual","message":"Comparing features","data":{"f1_id":"standard_messages","f2_id":"standard_messages","f1_name":"Standard Messages","f2_name":"Standard Messages","f1_type":"single_use","f2_type":"single_use"},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A,B"} +{"location":"previewTypes.ts:areFeaturesEqual:display_passed","message":"Display check passed","data":{"d1_exists":false,"d2_exists":true},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"B","runId":"post-fix"} +{"location":"previewTypes.ts:areFeaturesEqual:result","message":"Feature comparison result","data":{"featureId":"standard_messages","schemaEqual":true,"cs1":[],"cs2":[]},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"A"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038212325,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038253473,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038253474,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038253474,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038253474,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038253474,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038253474,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038255476,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038255476,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038255477,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:entry","message":"Checking changed features","data":{"hasInitial":true,"hasCurrent":true,"initialFeatureCount":4,"currentFeatureCount":4},"timestamp":1770038255477,"sessionId":"debug-session","hypothesisId":"D,E"} +{"location":"previewTypes.ts:getChangedFeatureIds:maps","message":"Feature maps built","data":{"initialIds":["energy","premium_messages","credits","standard_messages"],"currentIds":["energy","premium_messages","credits","standard_messages"]},"timestamp":1770038255477,"sessionId":"debug-session","hypothesisId":"E"} +{"location":"previewTypes.ts:getChangedFeatureIds:result","message":"Final changed feature IDs","data":{"changedIds":[]},"timestamp":1770038255477,"sessionId":"debug-session","hypothesisId":"D,E"} diff --git a/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts b/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts index 5b9d55975..dda780e4a 100644 --- a/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts +++ b/server/src/internal/misc/pricingAgent/handlers/handleSyncPreviewPricing.ts @@ -99,23 +99,34 @@ export const handleSyncPreviewPricing = createRoute({ features: [] as Awaited>, }; - // Create features - await Promise.all( - body.features.map((apiFeature) => { - const dbFeature = apiFeatureToDbFeature({ apiFeature }); - return createFeature({ - ctx: previewCtx, - data: { - id: dbFeature.id, - name: dbFeature.name, - type: dbFeature.type, - config: dbFeature.config, - event_names: dbFeature.event_names, - }, - skipGenerateDisplay: true, - }); - }), - ); + // Deduplicate features by ID (keep first occurrence) + const seenFeatureIds = new Set(); + const uniqueFeatures = body.features.filter((f) => { + if (seenFeatureIds.has(f.id)) { + ctx.logger.warn( + `[Preview Sync] Duplicate feature ID found: ${f.id}, skipping...`, + ); + return false; + } + seenFeatureIds.add(f.id); + return true; + }); + + // Create features sequentially to avoid race conditions + for (const apiFeature of uniqueFeatures) { + const dbFeature = apiFeatureToDbFeature({ apiFeature }); + await createFeature({ + ctx: previewCtx, + data: { + id: dbFeature.id, + name: dbFeature.name, + type: dbFeature.type, + config: dbFeature.config, + event_names: dbFeature.event_names, + }, + skipGenerateDisplay: true, + }); + } // Get updated features for product creation const updatedFeatures = await FeatureService.list({ diff --git a/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts b/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts index 37e5c9a4c..a66b2ffaf 100644 --- a/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts +++ b/server/src/internal/misc/pricingAgent/pricingAgentRouter.ts @@ -1,13 +1,13 @@ import { createAnthropic } from "@ai-sdk/anthropic"; -import { InternalError } from "@autumn/shared"; +import { type AgentPricingConfig, InternalError } from "@autumn/shared"; import { withTracing } from "@posthog/ai"; import { convertToModelMessages, streamText, type UIMessage } from "ai"; import { Hono } from "hono"; import { PostHog } from "posthog-node"; -import { z } from "zod/v4"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import { handleSetupPreviewOrg } from "./handlers/handleSetupPreviewOrg.js"; import { handleSyncPreviewPricing } from "./handlers/handleSyncPreviewPricing.js"; +import { OrganisationConfigurationSchema } from "./pricingAgentSchemas.js"; // PostHog client singleton let phClient: PostHog | null = null; @@ -23,226 +23,6 @@ const getPostHogClient = (): PostHog | null => { return phClient; }; -// ============ SCHEMAS ============ -const ApiFeatureType = z.enum([ - "static", - "boolean", - "single_use", - "continuous_use", - "credit_system", -]); - -const ProductItemInterval = z.enum([ - "minute", - "hour", - "day", - "week", - "month", - "quarter", - "semi_annual", - "year", -]); - -const UsageModel = z.enum(["prepaid", "pay_per_use"]); -const FreeTrialDuration = z.enum(["day", "month", "year"]); - -const FeatureSchema = z - .object({ - id: z - .string() - .describe( - "Unique ID for the feature (lowercase, underscores, no spaces)", - ), - name: z.string().describe("Display name for the feature"), - type: ApiFeatureType.describe( - "Type: single_use for consumables, continuous_use for allocated resources, boolean for on/off", - ), - display: z - .object({ - singular: z - .string() - .describe( - "Singular form of the unit (e.g., 'message', 'credit', 'seat', 'API call')", - ), - plural: z - .string() - .describe( - "Plural form of the unit (e.g., 'messages', 'credits', 'seats', 'API calls')", - ), - }) - .describe( - "REQUIRED for metered features (single_use, continuous_use, credit_system). Used for display like '100 messages' or '1 seat'.", - ), - credit_schema: z - .array( - z.object({ - metered_feature_id: z.string(), - credit_cost: z.number(), - }), - ) - .nullish(), - }) - .refine( - (data) => { - if (data.type === "credit_system") { - return data.credit_schema && data.credit_schema.length > 0; - } - return true; - }, - { - message: - "Credit system features require at least one metered feature in credit_schema.", - path: ["credit_schema"], - }, - ); - -const PriceTierSchema = z.object({ - to: z - .number() - .or(z.literal("inf")) - .describe("The upper limit of this tier (use 'inf' for unlimited)"), - amount: z.number().describe("The price per unit for this tier"), -}); - -const ProductItemSchema = z.object({ - feature_id: z - .string() - .nullish() - .describe( - "Feature ID this item relates to. Set to null for standalone flat-fee price items (e.g., subscription base price, one-time purchase price).", - ), - included_usage: z - .number() - .or(z.literal("inf")) - .nullish() - .describe( - "Usage granted to the customer. Use WITHOUT price for free allocations. Use WITH usage_model and price for metered pricing.", - ), - interval: ProductItemInterval.nullish().describe("Reset/billing interval"), - price: z - .number() - .nullish() - .describe( - "Price amount. When feature_id is null, this is a standalone flat fee. When feature_id is set with usage_model, this is the per-unit price.", - ), - tiers: z - .array(PriceTierSchema) - .nullish() - .describe( - "Tiered pricing structure. Use instead of price for volume-based pricing. Each tier defines upper limit (to) and price per unit (amount).", - ), - usage_model: UsageModel.nullish().describe( - "prepaid or pay_per_use. Required when pricing per unit of usage.", - ), - billing_units: z - .number() - .nullish() - .describe("Units per price (e.g., $1 per 30 credits)"), -}); - -const FreeTrialSchema = z - .object({ - length: z.number().describe("Length of free trial"), - duration: FreeTrialDuration.describe("Unit: day, month, or year"), - unique_fingerprint: z.boolean().default(false), - card_required: z.boolean().default(true), - }) - .nullish(); - -const ProductSchema = z - .object({ - id: z.string().describe("Unique ID (lowercase, hyphens allowed)"), - name: z.string().describe("Display name"), - is_add_on: z - .boolean() - .default(false) - .describe( - "Set to true if this product is an add-on or top-up, (can be purchased together with other base plans).", - ), - is_default: z - .boolean() - .default(false) - .describe( - "Set to true ONLY if the items array is completely empty OR contains only items with price: null. ANY pricing items (including pay-per-use, overage charges, prepaid etc.) disqualifies a plan from being default.", - ), - group: z - .string() - .default("") - .describe( - "A group to assign this plan to. Leave empty unless user is building pricing where a customer could subscribe to 2 or more types of plans at the same time.`", - ), - items: z.array(ProductItemSchema).default([]), - free_trial: FreeTrialSchema, - }) - .refine( - (data) => { - if (data.is_default) { - return data.items.every((item) => item.price == null); - } - return true; - }, - { - message: - "Default plans cannot have priced items. All items must have price: null or undefined.", - path: ["is_default"], - }, - ) - .refine( - (data) => { - const usageBasedFeatureIds = new Set( - data.items - .filter((item) => item.feature_id != null && item.usage_model != null) - .map((item) => item.feature_id), - ); - // Check if any other items reference the same feature_id - return !data.items.some( - (item) => - item.feature_id != null && - item.usage_model == null && - usageBasedFeatureIds.has(item.feature_id), - ); - }, - { - message: - "Cannot have separate items for the same feature when one has usage-based pricing. Combine into a single item (e.g., 100 free, then $0.10 per additional).", - path: ["items"], - }, - ) - .refine( - (data) => { - return !data.items.some( - (item) => item.usage_model === "pay_per_use" && item.interval == null, - ); - }, - { - message: - "Pay-per-use pricing requires an interval. Set interval (e.g., 'month') for usage-based items.", - path: ["items"], - }, - ) - .refine( - (data) => { - return !data.items.some( - (item) => - item.price != null && - item.feature_id != null && - item.usage_model == null, - ); - }, - { - message: - "Priced metered features require a usage_model. Set to 'pay_per_use' or 'prepaid'.", - path: ["items"], - }, - ); - -const OrganisationConfigurationSchema = z.object({ - features: z.array(FeatureSchema).default([]), - products: z.array(ProductSchema), -}); - -type PricingConfig = z.infer; - // ============ SYSTEM PROMPT ============ const SYSTEM_PROMPT = `You are a helpful pricing configuration assistant for Autumn, a billing and entitlements platform. @@ -331,8 +111,15 @@ This creates: $Y/month base price that includes 1 unit, then $Y per additional u export const pricingAgentRouter = new Hono(); pricingAgentRouter.post("/chat", async (c) => { - const { messages, sessionId }: { messages: UIMessage[]; sessionId?: string } = - await c.req.json(); + const { + messages, + sessionId, + initialConfig, + }: { + messages: UIMessage[]; + sessionId?: string; + initialConfig?: AgentPricingConfig | null; + } = await c.req.json(); const ctx = c.var.ctx; if (!process.env.ANTHROPIC_API_KEY) { @@ -342,6 +129,25 @@ pricingAgentRouter.post("/chat", async (c) => { }); } + // Build system prompt, optionally including initial config context + let systemPrompt = SYSTEM_PROMPT; + if ( + initialConfig && + (initialConfig.products.length > 0 || initialConfig.features.length > 0) + ) { + systemPrompt += ` + +## Current Pricing Configuration + +The user has an existing pricing setup that they want to modify. Here is their current configuration: + +\`\`\`json +${JSON.stringify(initialConfig, null, 2)} +\`\`\` + +When the user asks to make changes, modify this existing configuration rather than starting from scratch. Build upon what they already have unless they explicitly ask to start fresh.`; + } + // Create Anthropic client and optionally wrap with PostHog tracing const anthropicClient = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY, @@ -366,7 +172,7 @@ pricingAgentRouter.post("/chat", async (c) => { const result = streamText({ model, - system: SYSTEM_PROMPT, + system: systemPrompt, messages: await convertToModelMessages(messages), tools: { build_pricing: { diff --git a/server/src/internal/misc/pricingAgent/pricingAgentSchemas.ts b/server/src/internal/misc/pricingAgent/pricingAgentSchemas.ts new file mode 100644 index 000000000..786429b9b --- /dev/null +++ b/server/src/internal/misc/pricingAgent/pricingAgentSchemas.ts @@ -0,0 +1,262 @@ +import { z } from "zod/v4"; + +// ============ VALIDATION HELPERS ============ +const validateOneDefaultPerGroup = ({ + products, +}: { + products: { + is_default: boolean; + group: string; + free_trial?: { card_required: boolean } | null; + }[]; +}): boolean => { + const productsByGroup = new Map(); + + for (const product of products) { + const group = product.group || ""; + if (!productsByGroup.has(group)) { + productsByGroup.set(group, []); + } + productsByGroup.get(group)!.push(product); + } + + for (const [_, groupProducts] of productsByGroup) { + // Count products with is_default that DON'T have card-not-required free trials + const defaultWithoutCardlessTrialCount = groupProducts.filter( + (p) => + p.is_default && !(p.free_trial && p.free_trial.card_required === false), + ).length; + + if (defaultWithoutCardlessTrialCount > 1) { + return false; + } + } + + return true; +}; + +// ============ SCHEMAS ============ +const ApiFeatureType = z.enum([ + "static", + "boolean", + "single_use", + "continuous_use", + "credit_system", +]); + +const ProductItemInterval = z.enum([ + "minute", + "hour", + "day", + "week", + "month", + "quarter", + "semi_annual", + "year", +]); + +const UsageModel = z.enum(["prepaid", "pay_per_use"]); +const FreeTrialDuration = z.enum(["day", "month", "year"]); + +const FeatureSchema = z + .object({ + id: z + .string() + .describe( + "Unique ID for the feature (lowercase, underscores, no spaces)", + ), + name: z.string().describe("Display name for the feature"), + type: ApiFeatureType.describe( + "Type: single_use for consumables, continuous_use for allocated resources, boolean for on/off", + ), + display: z + .object({ + singular: z + .string() + .describe( + "Singular form of the unit (e.g., 'message', 'credit', 'seat', 'API call')", + ), + plural: z + .string() + .describe( + "Plural form of the unit (e.g., 'messages', 'credits', 'seats', 'API calls')", + ), + }) + .describe( + "REQUIRED for metered features (single_use, continuous_use, credit_system). Used for display like '100 messages' or '1 seat'.", + ), + credit_schema: z + .array( + z.object({ + metered_feature_id: z.string(), + credit_cost: z.number(), + }), + ) + .nullish(), + }) + .refine( + (data) => { + if (data.type === "credit_system") { + return data.credit_schema && data.credit_schema.length > 0; + } + return true; + }, + { + message: + "Credit system features require at least one metered feature in credit_schema.", + path: ["credit_schema"], + }, + ); + +const PriceTierSchema = z.object({ + to: z + .number() + .or(z.literal("inf")) + .describe("The upper limit of this tier (use 'inf' for unlimited)"), + amount: z.number().describe("The price per unit for this tier"), +}); + +const ProductItemSchema = z.object({ + feature_id: z + .string() + .nullish() + .describe( + "Feature ID this item relates to. Set to null for standalone flat-fee price items (e.g., subscription base price, one-time purchase price).", + ), + included_usage: z + .number() + .or(z.literal("inf")) + .nullish() + .describe( + "Usage granted to the customer. Use WITHOUT price for free allocations. Use WITH usage_model and price for metered pricing.", + ), + interval: ProductItemInterval.nullish().describe("Reset/billing interval"), + price: z + .number() + .nullish() + .describe( + "Price amount. When feature_id is null, this is a standalone flat fee. When feature_id is set with usage_model, this is the per-unit price.", + ), + tiers: z + .array(PriceTierSchema) + .nullish() + .describe( + "Tiered pricing structure. Use instead of price for volume-based pricing. Each tier defines upper limit (to) and price per unit (amount).", + ), + usage_model: UsageModel.nullish().describe( + "prepaid or pay_per_use. Required when pricing per unit of usage.", + ), + billing_units: z + .number() + .nullish() + .describe("Units per price (e.g., $1 per 30 credits)"), +}); + +const FreeTrialSchema = z + .object({ + length: z.number().describe("Length of free trial"), + duration: FreeTrialDuration.describe("Unit: day, month, or year"), + unique_fingerprint: z.boolean().default(false), + card_required: z.boolean().default(true), + }) + .nullish(); + +const ProductSchema = z + .object({ + id: z.string().describe("Unique ID (lowercase, hyphens allowed)"), + name: z.string().describe("Display name"), + is_add_on: z + .boolean() + .default(false) + .describe( + "Set to true if this product is an add-on or top-up, (can be purchased together with other base plans).", + ), + is_default: z + .boolean() + .default(false) + .describe( + "Set to true ONLY if the items array is completely empty OR contains only items with price: null. ANY pricing items (including pay-per-use, overage charges, prepaid etc.) disqualifies a plan from being default.", + ), + group: z + .string() + .default("") + .describe( + "A group to assign this plan to. Leave empty unless user is building pricing where a customer could subscribe to 2 or more types of plans at the same time.`", + ), + items: z.array(ProductItemSchema).default([]), + free_trial: FreeTrialSchema, + }) + .refine( + (data) => { + if (data.is_default) { + return data.items.every((item) => item.price == null); + } + return true; + }, + { + message: + "Default plans cannot have priced items. All items must have price: null or undefined.", + path: ["is_default"], + }, + ) + .refine( + (data) => { + const usageBasedFeatureIds = new Set( + data.items + .filter((item) => item.feature_id != null && item.usage_model != null) + .map((item) => item.feature_id), + ); + // Check if any other items reference the same feature_id + return !data.items.some( + (item) => + item.feature_id != null && + item.usage_model == null && + usageBasedFeatureIds.has(item.feature_id), + ); + }, + { + message: + "Cannot have separate items for the same feature when one has usage-based pricing. Combine into a single item (e.g., 100 free, then $0.10 per additional).", + path: ["items"], + }, + ) + .refine( + (data) => { + return !data.items.some( + (item) => item.usage_model === "pay_per_use" && item.interval == null, + ); + }, + { + message: + "Pay-per-use pricing requires an interval. Set interval (e.g., 'month') for usage-based items.", + path: ["items"], + }, + ) + .refine( + (data) => { + return !data.items.some( + (item) => + item.price != null && + item.feature_id != null && + item.usage_model == null, + ); + }, + { + message: + "Priced metered features require a usage_model. Set to 'pay_per_use' or 'prepaid'.", + path: ["items"], + }, + ); + +export const OrganisationConfigurationSchema = z + .object({ + features: z.array(FeatureSchema).default([]), + products: z.array(ProductSchema), + }) + .refine((data) => validateOneDefaultPerGroup({ products: data.products }), { + message: + "Only one plan per group can have is_default: true, unless it also has a free trial with card_required: false.", + path: ["products"], + }); + +export type PricingConfig = z.infer; diff --git a/shared/index.ts b/shared/index.ts index b0f8c13dc..a8e17de39 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -172,6 +172,8 @@ export * from "./models/rewardModels/rewardProgramModels/rewardProgramModels.js" export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable.js"; export * from "./models/subModels/subModels.js"; export * from "./models/subModels/subTable.js"; +// Agent Types (for pricing agent AI) +export * from "./utils/agentTypes.js"; export * from "./utils/billingUtils/index.js"; // Checkout Utils export * from "./utils/checkoutUtils/index.js"; diff --git a/shared/utils/agentTypes.ts b/shared/utils/agentTypes.ts new file mode 100644 index 000000000..b2d0abfdb --- /dev/null +++ b/shared/utils/agentTypes.ts @@ -0,0 +1,311 @@ +/** + * Agent Types - Types and converters for the AI pricing agent + * + * The "Agent" format is a simplified, AI-friendly format used by the pricing agent. + * It uses string literals like "single_use" instead of enums, making it easier for + * LLMs to generate and for users to read. + * + * This module provides: + * - TypeScript interfaces for the agent format + * - Converters: AgentFeature ↔ Feature, AgentProduct ↔ ProductV2 + */ + +import { + FeatureType, + FeatureUsageType, +} from "../models/featureModels/featureEnums.js"; +import type { Feature } from "../models/featureModels/featureModels.js"; +import { AppEnv } from "../models/genModels/genEnums.js"; +import { Infinite } from "../models/productModels/productEnums.js"; +import type { ProductItem } from "../models/productV2Models/productItemModels/productItemModels.js"; +import type { ProductV2 } from "../models/productV2Models/productV2Models.js"; + +// ============ INTERFACES ============ + +export type AgentFeatureType = + | "static" + | "boolean" + | "single_use" + | "continuous_use" + | "credit_system"; + +export interface AgentFeature { + id: string; + name?: string | null; + type: AgentFeatureType; + display?: { + singular: string; + plural: string; + } | null; + credit_schema?: Array<{ + metered_feature_id: string; + credit_cost: number; + }> | null; +} + +export interface AgentProductItem { + feature_id?: string | null; + included_usage?: number | "inf" | null; + interval?: string | null; + price?: number | null; + tiers?: Array<{ to: number | "inf"; amount: number }> | null; + usage_model?: "prepaid" | "pay_per_use" | null; + billing_units?: number | null; +} + +export interface AgentFreeTrial { + length: number; + duration: "day" | "month" | "year"; + unique_fingerprint?: boolean; + card_required?: boolean; +} + +export interface AgentProduct { + id: string; + name: string; + is_add_on?: boolean; + is_default?: boolean; + group?: string; + items?: AgentProductItem[]; + free_trial?: AgentFreeTrial | null; +} + +export interface AgentPricingConfig { + features: AgentFeature[]; + products: AgentProduct[]; +} + +// ============ AGENT → SHARED CONVERTERS ============ + +function mapAgentTypeToFeatureType(agentType: AgentFeatureType): FeatureType { + switch (agentType) { + case "boolean": + case "static": + return FeatureType.Boolean; + case "credit_system": + return FeatureType.CreditSystem; + default: + return FeatureType.Metered; + } +} + +function mapAgentTypeToUsageType( + agentType: AgentFeatureType, +): FeatureUsageType | null { + switch (agentType) { + case "single_use": + return FeatureUsageType.Single; + case "continuous_use": + return FeatureUsageType.Continuous; + default: + return null; + } +} + +/** Convert AgentFeature → Feature (shared DB type) */ +export function agentFeatureToFeature(agentFeature: AgentFeature): Feature { + const usageType = mapAgentTypeToUsageType(agentFeature.type); + + const config: Record = {}; + if (usageType) { + config.usage_type = usageType; + } + if (agentFeature.credit_schema) { + config.schema = agentFeature.credit_schema.map((s) => ({ + metered_feature_id: s.metered_feature_id, + credit_amount: s.credit_cost, + })); + } + + return { + internal_id: agentFeature.id, + org_id: "", + created_at: Date.now(), + env: AppEnv.Sandbox, + id: agentFeature.id, + name: agentFeature.name ?? agentFeature.display?.plural ?? agentFeature.id, + type: mapAgentTypeToFeatureType(agentFeature.type), + config: Object.keys(config).length > 0 ? config : null, + display: agentFeature.display ?? undefined, + archived: false, + event_names: [], + }; +} + +/** Convert AgentProductItem → ProductItem (shared DB type) */ +export function agentItemToProductItem(item: AgentProductItem): ProductItem { + return { + feature_id: item.feature_id ?? undefined, + included_usage: + item.included_usage === "inf" + ? Infinite + : (item.included_usage ?? undefined), + interval: item.interval as ProductItem["interval"], + price: item.price ?? undefined, + billing_units: item.billing_units ?? undefined, + usage_model: item.usage_model as ProductItem["usage_model"], + tiers: item.tiers?.map((t) => ({ + to: t.to === "inf" ? Infinite : t.to, + amount: t.amount, + })), + }; +} + +/** Convert AgentProduct → ProductV2 (shared DB type) */ +export function agentProductToProductV2(product: AgentProduct): ProductV2 { + return { + internal_id: product.id, + id: product.id, + name: product.name, + description: null, + is_add_on: product.is_add_on ?? false, + is_default: product.is_default ?? false, + version: 1, + group: product.group ?? null, + env: AppEnv.Sandbox, + free_trial: null, // Handled separately in preview transformations + items: (product.items ?? []).map(agentItemToProductItem), + created_at: Date.now(), + }; +} + +// ============ SHARED → AGENT CONVERTERS ============ + +function mapFeatureTypeToAgentType(feature: Feature): AgentFeatureType { + if (feature.type === FeatureType.CreditSystem) { + return "credit_system"; + } + + if (feature.type === FeatureType.Boolean) { + return "boolean"; + } + + if (feature.type === FeatureType.Metered) { + const usageType = feature.config?.usage_type; + if ( + usageType === "continuous_use" || + usageType === FeatureUsageType.Continuous + ) { + return "continuous_use"; + } + return "single_use"; + } + + return "static"; +} + +/** Convert Feature → AgentFeature */ +export function featureToAgentFeature(feature: Feature): AgentFeature { + const agentFeature: AgentFeature = { + id: feature.id, + name: feature.name, + type: mapFeatureTypeToAgentType(feature), + }; + + if (feature.display?.singular || feature.display?.plural) { + agentFeature.display = { + singular: feature.display.singular ?? feature.name, + plural: feature.display.plural ?? feature.name, + }; + } + + if (feature.type === FeatureType.CreditSystem && feature.config?.schema) { + agentFeature.credit_schema = feature.config.schema.map( + (s: { metered_feature_id: string; credit_amount: number }) => ({ + metered_feature_id: s.metered_feature_id, + credit_cost: s.credit_amount, + }), + ); + } + + return agentFeature; +} + +/** Convert ProductItem → AgentProductItem */ +export function productItemToAgentItem(item: ProductItem): AgentProductItem { + const agentItem: AgentProductItem = {}; + + if (item.feature_id) { + agentItem.feature_id = item.feature_id; + } + + if (item.included_usage !== undefined && item.included_usage !== null) { + agentItem.included_usage = + item.included_usage === Infinite ? "inf" : item.included_usage; + } + + if (item.interval) { + agentItem.interval = item.interval; + } + + if (item.price !== undefined && item.price !== null) { + agentItem.price = item.price; + } + + // Copy tiers if present (tiered pricing) + if (item.tiers && item.tiers.length > 0) { + agentItem.tiers = item.tiers.map((t) => ({ + to: t.to === Infinite ? "inf" : t.to, + amount: t.amount, + })); + } + + if (item.usage_model) { + agentItem.usage_model = item.usage_model as "prepaid" | "pay_per_use"; + } + + if (item.billing_units) { + agentItem.billing_units = item.billing_units; + } + + return agentItem; +} + +/** Convert ProductV2 → AgentProduct */ +export function productV2ToAgentProduct(product: ProductV2): AgentProduct { + const agentProduct: AgentProduct = { + id: product.id, + name: product.name, + }; + + if (product.is_add_on) { + agentProduct.is_add_on = true; + } + + if (product.is_default) { + agentProduct.is_default = true; + } + + if (product.group) { + agentProduct.group = product.group; + } + + if (product.items && product.items.length > 0) { + agentProduct.items = product.items.map(productItemToAgentItem); + } + + if (product.free_trial) { + agentProduct.free_trial = { + length: product.free_trial.length, + duration: product.free_trial.duration as "day" | "month" | "year", + unique_fingerprint: product.free_trial.unique_fingerprint, + card_required: product.free_trial.card_required, + }; + } + + return agentProduct; +} + +/** Convert ProductV2[] and Feature[] → AgentPricingConfig */ +export function convertToAgentConfig({ + products, + features, +}: { + products: ProductV2[]; + features: Feature[]; +}): AgentPricingConfig { + return { + features: features.map(featureToAgentFeature), + products: products.map(productV2ToAgentProduct), + }; +} diff --git a/shared/utils/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts index 637bb8b59..94768e850 100644 --- a/shared/utils/productDisplayUtils.ts +++ b/shared/utils/productDisplayUtils.ts @@ -192,7 +192,11 @@ export const getFeaturePriceItemDisplay = ({ : ""; // Build price string (e.g., "$0.01") - const priceStr = formatTiers({ item, currency, amountFormatOptions }) ?? ""; + const priceStr = + formatTiers({ item, currency, amountFormatOptions }) ?? + (notNullish(item.price) + ? formatAmount({ currency, amount: item.price, amountFormatOptions }) + : ""); // Build billing unit string (e.g., "credit" or "100 credits") const billingUnits = item.billing_units ?? 1; diff --git a/vite/src/components/ai-elements/CompactPromptInput.tsx b/vite/src/components/ai-elements/CompactPromptInput.tsx new file mode 100644 index 000000000..e68542ecd --- /dev/null +++ b/vite/src/components/ai-elements/CompactPromptInput.tsx @@ -0,0 +1,64 @@ +import { CornerDownLeftIcon, Loader2Icon } from "lucide-react"; +import type { FormEvent, KeyboardEvent } from "react"; +import { InputGroupButton } from "@/components/ui/input-group"; +import { cn } from "@/lib/utils"; + +/** + * Compact inline prompt input with submit button on the same line. + * Designed for simple text input without attachments. + */ +export function CompactPromptInput({ + value, + onChange, + onSubmit, + placeholder = "Describe changes...", + isLoading, + className, +}: { + value: string; + onChange: (value: string) => void; + onSubmit: () => void; + placeholder?: string; + isLoading?: boolean; + className?: string; +}) { + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (value.trim() && !isLoading) onSubmit(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey && value.trim() && !isLoading) { + e.preventDefault(); + onSubmit(); + } + }; + + return ( +
+
+ onChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={isLoading} + className="flex-1 bg-transparent text-sm outline-none placeholder:text-t4" + /> + + {isLoading ? ( + + ) : ( + + )} + +
+
+ ); +} diff --git a/vite/src/components/general/table/table-row-cells.tsx b/vite/src/components/general/table/table-row-cells.tsx index 91d55700a..0ed575daf 100644 --- a/vite/src/components/general/table/table-row-cells.tsx +++ b/vite/src/components/general/table/table-row-cells.tsx @@ -121,7 +121,7 @@ export function TableEmptyState({ ) : ( -
+
{emptyStateChildren || emptyStateText}
)} diff --git a/vite/src/views/customers2/components/table/EmptyState.tsx b/vite/src/views/customers2/components/table/EmptyState.tsx index 31e21ad7e..ce0b9bab4 100644 --- a/vite/src/views/customers2/components/table/EmptyState.tsx +++ b/vite/src/views/customers2/components/table/EmptyState.tsx @@ -1,7 +1,7 @@ export const EmptyState = ({ text }: { text: string | React.ReactNode }) => { return (
- {text} + {text}
); }; diff --git a/vite/src/views/onboarding4/AIChatView.tsx b/vite/src/views/onboarding4/AIChatView.tsx index f8756b50e..3d6d24212 100644 --- a/vite/src/views/onboarding4/AIChatView.tsx +++ b/vite/src/views/onboarding4/AIChatView.tsx @@ -1,276 +1,50 @@ -import { useChat } from "@ai-sdk/react"; -import { - DefaultChatTransport, - lastAssistantMessageIsCompleteWithToolCalls, -} from "ai"; -import { ImageIcon } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useId, useRef, useState } from "react"; -import { - Conversation, - ConversationContent, -} from "@/components/ai-elements/conversation"; -import { - Message, - MessageContent, - MessageResponse, -} from "@/components/ai-elements/message"; import { PromptInput, - PromptInputAttachment, - PromptInputAttachments, PromptInputBody, - PromptInputButton, PromptInputFooter, - PromptInputHeader, - type PromptInputMessage, PromptInputSubmit, PromptInputTextarea, - usePromptInputAttachments, } from "@/components/ai-elements/prompt-input"; -import { Shimmer } from "@/components/ai-elements/shimmer"; import { Button } from "@/components/v2/buttons/Button"; -import { useAxiosInstance } from "@/services/useAxiosInstance"; import { OrgDropdown } from "@/views/main-sidebar/components/OrgDropdown"; import { SidebarContext } from "@/views/main-sidebar/SidebarContext"; import { CopyPlansButton } from "./CopyPlansButton"; +import { + AttachmentsHeader, + ImageUploadButton, +} from "./components/ChatInputComponents"; +import { PricingChatPanel } from "./components/PricingChatPanel"; import { TemplatePrompts } from "./components/TemplatePrompts"; import { WelcomeHeader } from "./components/WelcomeHeader"; +import { usePricingAgentChat } from "./hooks/usePricingAgentChat"; import { PricingConfigSheet } from "./PricingConfigSheet"; import { PricingPreview } from "./PricingPreview"; -import type { AgentPricingConfig } from "./pricingAgentUtils"; interface AIChatViewProps { onBack: () => void; } -function ImageUploadButton({ disabled }: { disabled?: boolean }) { - const attachments = usePromptInputAttachments(); - - return ( - attachments.openFileDialog()} - disabled={disabled} - title="Add image" - > - - - ); -} - -function AttachmentsHeader() { - const attachments = usePromptInputAttachments(); - - if (!attachments.files.length) { - return null; - } - - return ( - - - {(attachment) => } - - - ); -} - -// Type for the build_pricing tool part -type BuildPricingToolPart = { - type: "tool-build_pricing"; - toolCallId: string; - toolName: "build_pricing"; - state: - | "input-streaming" - | "input-available" - | "output-available" - | "output-error"; - input?: AgentPricingConfig; - output?: unknown; - errorText?: string; -}; - -interface PreviewOrg { - apiKey: string; - orgId: string; - orgSlug: string; -} - export function AIChatView({ onBack }: AIChatViewProps) { - const [input, setInput] = useState(""); - const [hasStartedChat, setHasStartedChat] = useState(false); - const [pricingConfig, setPricingConfig] = useState( - null, - ); - const [jsonSheetConfig, setJsonSheetConfig] = - useState(null); - const [previewOrg, setPreviewOrg] = useState(null); - const [isPreviewSyncing, setIsPreviewSyncing] = useState(false); - const previewSetupRef = useRef | null>(null); - const axiosInstance = useAxiosInstance(); - - // Session ID for PostHog AI tracing - groups all messages in a conversation - const reactId = useId(); - const chatSessionIdRef = useRef(`pricing-chat-${reactId}-${Date.now()}`); - - /** Setup the preview org (called once, memoized) */ - const setupPreviewOrg = useCallback(async (): Promise => { - // If already setting up, return the existing promise - if (previewSetupRef.current) { - return previewSetupRef.current; - } - - const setupPromise = (async () => { - try { - console.log("[Preview] Setting up preview org..."); - const response = await fetch( - `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/preview/setup`, - { - method: "POST", - credentials: "include", - headers: { - "x-client-type": "dashboard", - "Content-Type": "application/json", - }, - }, - ); - - if (!response.ok) { - const error = await response.json(); - console.error("[Preview] Setup failed:", error); - return null; - } - - const data = await response.json(); - const org: PreviewOrg = { - apiKey: data.api_key, - orgId: data.org_id, - orgSlug: data.org_slug, - }; - console.log("[Preview] Setup complete:", { - orgId: org.orgId, - orgSlug: org.orgSlug, - }); - setPreviewOrg(org); - return org; - } catch (error) { - console.error("[Preview] Setup error:", error); - return null; - } - })(); - - previewSetupRef.current = setupPromise; - return setupPromise; - }, []); - - /** Sync pricing config to the preview org */ - const syncPreviewPricing = useCallback( - async (config: AgentPricingConfig) => { - // Ensure preview org is set up - let org = previewOrg; - if (!org) { - org = await setupPreviewOrg(); - if (!org) { - console.error("[Preview] Cannot sync - preview org not available"); - return; - } - } - - setIsPreviewSyncing(true); - try { - console.log("[Preview] Syncing pricing config..."); - console.log("[Preview] Features:", config.features.length); - console.log("[Preview] Products:", config.products.length); - - const response = await axiosInstance.post( - "/pricing-agent/preview/sync", - { - features: config.features, - products: config.products, - }, - ); - - console.log("[Preview] Sync complete:", response.data); - } catch (error) { - console.error("[Preview] Sync error:", error); - } finally { - setIsPreviewSyncing(false); - } - }, - [axiosInstance, previewOrg, setupPreviewOrg], - ); - - const { messages, sendMessage, status, addToolOutput } = useChat({ - transport: new DefaultChatTransport({ - api: `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/chat`, - credentials: "include", - headers: { - "x-client-type": "dashboard", - }, - body: { - sessionId: chatSessionIdRef.current, - }, - }), - - // Auto-submit when all tool results are available (for multi-step if needed) - sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, - - // Handle client-side tool execution - onToolCall: async ({ toolCall }) => { - // Check for dynamic tools first - if (toolCall.dynamic) { - return; - } - - if (toolCall.toolName === "build_pricing") { - const config = toolCall.input as AgentPricingConfig; - - // Update the pricing preview - setPricingConfig(config); - - // Sync to preview org (fire and forget) - syncPreviewPricing(config); - - // Return the tool result (no await to avoid deadlocks) - addToolOutput({ - tool: "build_pricing", - toolCallId: toolCall.toolCallId, - output: { - success: true, - productsCount: config.products.length, - featuresCount: config.features.length, - }, - }); - } - }, - }); - - const handleSubmit = (message: PromptInputMessage) => { - if ( - (!message.text.trim() && message.files.length === 0) || - status !== "ready" - ) - return; - - setHasStartedChat(true); - sendMessage({ - text: message.text, - files: message.files, - }); - setInput(""); - }; + const { + messages, + input, + setInput, + isLoading, + hasStartedChat, + pricingConfig, + previewOrg, + isPreviewSyncing, + jsonSheetConfig, + setJsonSheetConfig, + handleSubmit, + handleStartNewChat, + } = usePricingAgentChat(); const handleSelectTemplate = ({ prompt }: { prompt: string }) => { setInput(prompt); }; - const isLoading = status === "streaming" || status === "submitted"; - - const handleStartNewChat = () => { - setHasStartedChat(false); - setInput(""); - setPricingConfig(null); - }; - return ( {} }}>
@@ -389,124 +163,16 @@ export function AIChatView({ onBack }: AIChatViewProps) { transition={{ duration: 0.4, delay: 0.3 }} className="w-1/3 flex flex-col pt-14" > - - - {messages.map((message) => ( - - - {message.parts.map((part, partIndex) => { - switch (part.type) { - case "text": - return ( - - {part.text} - - ); - - case "file": { - const isImage = - part.mediaType?.startsWith("image/"); - if (!isImage || !part.url) return null; - return ( -
- {part.filename -
- ); - } - - case "tool-build_pricing": { - const toolPart = part as BuildPricingToolPart; - return ( -
- {toolPart.state === "input-streaming" || - toolPart.state === "input-available" ? ( - - Building pricing configuration - - ) : toolPart.state === "output-error" ? ( - - Error generating pricing - - ) : ( - <> - - Generated{" "} - {toolPart.input?.products.length ?? - 0}{" "} - product(s) and{" "} - {toolPart.input?.features.length ?? - 0}{" "} - feature(s) - - - - )} -
- ); - } - - default: - return null; - } - })} -
-
- ))} - {isLoading && - messages.length > 0 && - messages[messages.length - 1]?.role === "user" && ( -
- Planning next steps -
- )} -
-
- -
- - - - setInput(e.target.value)} - placeholder="Describe your app's pricing" - disabled={isLoading} - /> - - - - - - -
+ {/* Right: Pricing Preview */} diff --git a/vite/src/views/onboarding4/CopyPlansButton.tsx b/vite/src/views/onboarding4/CopyPlansButton.tsx index e4679f1a4..cd1bf930a 100644 --- a/vite/src/views/onboarding4/CopyPlansButton.tsx +++ b/vite/src/views/onboarding4/CopyPlansButton.tsx @@ -1,3 +1,4 @@ +import type { AgentPricingConfig } from "@autumn/shared"; import { useState } from "react"; import { useNavigate } from "react-router"; import { toast } from "sonner"; @@ -15,7 +16,6 @@ import { useOrg } from "@/hooks/common/useOrg"; import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr, pushPage } from "@/utils/genUtils"; -import type { AgentPricingConfig } from "./pricingAgentUtils"; interface CopyPlansButtonProps { pricingConfig: AgentPricingConfig; diff --git a/vite/src/views/onboarding4/OnboardingGuide.tsx b/vite/src/views/onboarding4/OnboardingGuide.tsx index 5ab58311f..3e76f2da0 100644 --- a/vite/src/views/onboarding4/OnboardingGuide.tsx +++ b/vite/src/views/onboarding4/OnboardingGuide.tsx @@ -105,7 +105,7 @@ function StepCard({ animate={{ flex: isActive ? 4 : 1 }} transition={{ duration: 0.3, ease: "easeInOut" }} className={cn( - "relative border dark:border-none rounded-xl bg-card cursor-pointer h-29 overflow-hidden", + "relative dark:border-none rounded-xl bg-card cursor-pointer h-29 overflow-hidden", isActive ? "" : "hover:border-primary/20 hover:bg-interactive-secondary-hover", @@ -277,7 +277,7 @@ export function OnboardingGuide() { } return ( -
+
{/* Dismiss button */}
diff --git a/vite/src/views/onboarding4/PricingConfigSheet.tsx b/vite/src/views/onboarding4/PricingConfigSheet.tsx index 73b5e3c34..196b8c555 100644 --- a/vite/src/views/onboarding4/PricingConfigSheet.tsx +++ b/vite/src/views/onboarding4/PricingConfigSheet.tsx @@ -1,3 +1,4 @@ +import type { AgentPricingConfig } from "@autumn/shared"; import { CodeGroup, CodeGroupCode, @@ -11,7 +12,6 @@ import { SheetHeader, SheetTitle, } from "@/components/v2/sheets/Sheet"; -import type { AgentPricingConfig } from "./pricingAgentUtils"; interface PricingConfigSheetProps { open: boolean; diff --git a/vite/src/views/onboarding4/PricingPreview.tsx b/vite/src/views/onboarding4/PricingPreview.tsx index 13d36f24c..f41938fd3 100644 --- a/vite/src/views/onboarding4/PricingPreview.tsx +++ b/vite/src/views/onboarding4/PricingPreview.tsx @@ -1,8 +1,12 @@ +import type { AgentPricingConfig } from "@autumn/shared"; import type { ReactNode } from "react"; +import { GroupedPlanCards } from "./preview/GroupedPlanCards"; import { PreviewCreditSchemaCard } from "./preview/PreviewCreditSchemaCard"; -import { PreviewPlanCard } from "./preview/PreviewPlanCard"; -import { transformToPreviewProducts } from "./preview/previewTypes"; -import type { AgentPricingConfig } from "./pricingAgentUtils"; +import { + getChangedFeatureIds, + getChangedProductIds, + transformToPreviewProducts, +} from "./preview/previewTypes"; interface PreviewOrg { apiKey: string; @@ -12,6 +16,7 @@ interface PreviewOrg { interface PricingPreviewProps { config: AgentPricingConfig | null; + initialConfig?: AgentPricingConfig | null; previewOrg: PreviewOrg | null; isSyncing: boolean; headerActions?: ReactNode; @@ -19,6 +24,7 @@ interface PricingPreviewProps { export function PricingPreview({ config, + initialConfig, previewOrg, isSyncing, headerActions, @@ -32,6 +38,18 @@ export function PricingPreview({ }) : []; + // Compute which products have changed from initial config + const changedProductIds = getChangedProductIds({ + initialConfig: initialConfig ?? null, + currentConfig: config, + }); + + // Compute which features have changed from initial config + const changedFeatureIds = getChangedFeatureIds({ + initialConfig: initialConfig ?? null, + currentConfig: config, + }); + // Find credit system features to display their schemas const creditSystemFeatures = hasProducts ? config.features.filter( @@ -65,7 +83,7 @@ export function PricingPreview({ {/* Content area with dotted grid background */}
{hasProducts && ( - <> -
- {previewProducts.map((product) => ( - - ))} -
+
+ {/* Credit system schema cards */} {creditSystemFeatures.length > 0 && ( @@ -93,11 +107,12 @@ export function PricingPreview({ key={creditFeature.id} creditFeature={creditFeature} allFeatures={config.features} + isChanged={changedFeatureIds.has(creditFeature.id)} /> ))}
)} - +
)}
diff --git a/vite/src/views/onboarding4/components/ChatInputComponents.tsx b/vite/src/views/onboarding4/components/ChatInputComponents.tsx new file mode 100644 index 000000000..77ef33ad9 --- /dev/null +++ b/vite/src/views/onboarding4/components/ChatInputComponents.tsx @@ -0,0 +1,71 @@ +import type { AgentPricingConfig } from "@autumn/shared"; +import { ImageIcon } from "lucide-react"; +import { + PromptInputAttachment, + PromptInputAttachments, + PromptInputButton, + PromptInputHeader, + usePromptInputAttachments, +} from "@/components/ai-elements/prompt-input"; + +/** + * Type for the build_pricing tool part returned by the AI + */ +export type BuildPricingToolPart = { + type: "tool-build_pricing"; + toolCallId: string; + toolName: "build_pricing"; + state: + | "input-streaming" + | "input-available" + | "output-available" + | "output-error"; + input?: AgentPricingConfig; + output?: unknown; + errorText?: string; +}; + +/** + * Preview org configuration for syncing pricing + */ +export interface PreviewOrg { + apiKey: string; + orgId: string; + orgSlug: string; +} + +/** + * Button to open file dialog for image uploads in the chat input + */ +export function ImageUploadButton({ disabled }: { disabled?: boolean }) { + const attachments = usePromptInputAttachments(); + + return ( + attachments.openFileDialog()} + disabled={disabled} + title="Add image" + > + + + ); +} + +/** + * Header showing attached files in the prompt input + */ +export function AttachmentsHeader() { + const attachments = usePromptInputAttachments(); + + if (!attachments.files.length) { + return null; + } + + return ( + + + {(attachment) => } + + + ); +} diff --git a/vite/src/views/onboarding4/components/PricingChatPanel.tsx b/vite/src/views/onboarding4/components/PricingChatPanel.tsx new file mode 100644 index 000000000..6099d51bd --- /dev/null +++ b/vite/src/views/onboarding4/components/PricingChatPanel.tsx @@ -0,0 +1,169 @@ +import type { AgentPricingConfig } from "@autumn/shared"; +import type { UIMessage } from "ai"; +import { + Conversation, + ConversationContent, +} from "@/components/ai-elements/conversation"; +import { + Message, + MessageContent, + MessageResponse, +} from "@/components/ai-elements/message"; +import { + PromptInput, + PromptInputBody, + PromptInputFooter, + type PromptInputMessage, + PromptInputSubmit, + PromptInputTextarea, +} from "@/components/ai-elements/prompt-input"; +import { Shimmer } from "@/components/ai-elements/shimmer"; +import { Button } from "@/components/v2/buttons/Button"; +import { cn } from "@/lib/utils"; +import { + AttachmentsHeader, + type BuildPricingToolPart, + ImageUploadButton, +} from "./ChatInputComponents"; + +interface PricingChatPanelProps { + messages: UIMessage[]; + input: string; + onInputChange: (value: string) => void; + onSubmit: (message: PromptInputMessage) => void; + isLoading: boolean; + onViewJson?: (config: AgentPricingConfig) => void; + placeholder?: string; + className?: string; + inputClassName?: string; +} + +/** + * Reusable chat panel for pricing agent conversations. + * Renders messages list and prompt input. + */ +export function PricingChatPanel({ + messages, + input, + onInputChange, + onSubmit, + isLoading, + onViewJson, + placeholder = "Describe your app's pricing", + className, + inputClassName, +}: PricingChatPanelProps) { + return ( +
+ + + {messages.map((message) => ( + + + {message.parts.map((part, partIndex) => { + switch (part.type) { + case "text": + return ( + + {part.text} + + ); + + case "file": { + const isImage = part.mediaType?.startsWith("image/"); + if (!isImage || !part.url) return null; + return ( +
+ {part.filename +
+ ); + } + + case "tool-build_pricing": { + const toolPart = part as BuildPricingToolPart; + return ( +
+ {toolPart.state === "input-streaming" || + toolPart.state === "input-available" ? ( + + Building pricing configuration + + ) : toolPart.state === "output-error" ? ( + + Error generating pricing + + ) : ( + <> + + Generated {toolPart.input?.products.length ?? 0}{" "} + product(s) and{" "} + {toolPart.input?.features.length ?? 0}{" "} + feature(s) + + {onViewJson && toolPart.input && ( + + )} + + )} +
+ ); + } + + default: + return null; + } + })} +
+
+ ))} + {isLoading && + messages.length > 0 && + messages[messages.length - 1]?.role === "user" && ( +
+ Planning next steps +
+ )} +
+
+ +
+ + + + onInputChange(e.target.value)} + placeholder={placeholder} + disabled={isLoading} + /> + + + + + + +
+
+ ); +} diff --git a/vite/src/views/onboarding4/hooks/usePricingAgentChat.ts b/vite/src/views/onboarding4/hooks/usePricingAgentChat.ts new file mode 100644 index 000000000..f388a6e99 --- /dev/null +++ b/vite/src/views/onboarding4/hooks/usePricingAgentChat.ts @@ -0,0 +1,244 @@ +import { useChat } from "@ai-sdk/react"; +import type { AgentPricingConfig } from "@autumn/shared"; +import { + DefaultChatTransport, + lastAssistantMessageIsCompleteWithToolCalls, +} from "ai"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import type { PreviewOrg } from "../components/ChatInputComponents"; + +export interface UsePricingAgentChatOptions { + initialConfig?: AgentPricingConfig | null; +} + +export function usePricingAgentChat(options?: UsePricingAgentChatOptions) { + const [input, setInput] = useState(""); + const [hasStartedChat, setHasStartedChat] = useState( + options?.initialConfig != null, + ); + const [pricingConfig, setPricingConfig] = useState( + options?.initialConfig ?? null, + ); + const [jsonSheetConfig, setJsonSheetConfig] = + useState(null); + const [previewOrg, setPreviewOrg] = useState(null); + const [isPreviewSyncing, setIsPreviewSyncing] = useState(false); + const previewSetupRef = useRef | null>(null); + const syncInProgressRef = useRef(false); + const initialSyncDoneRef = useRef(false); + const axiosInstance = useAxiosInstance(); + + // Session ID for PostHog AI tracing - groups all messages in a conversation + const reactId = useId(); + const chatSessionIdRef = useRef(`pricing-chat-${reactId}-${Date.now()}`); + + /** Setup the preview org (called once, memoized) */ + const setupPreviewOrg = useCallback(async (): Promise => { + // If already setting up, return the existing promise + if (previewSetupRef.current) { + return previewSetupRef.current; + } + + const setupPromise = (async () => { + try { + console.log("[Preview] Setting up preview org..."); + const response = await fetch( + `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/preview/setup`, + { + method: "POST", + credentials: "include", + headers: { + "x-client-type": "dashboard", + "Content-Type": "application/json", + }, + }, + ); + + if (!response.ok) { + const error = await response.json(); + console.error("[Preview] Setup failed:", error); + return null; + } + + const data = await response.json(); + const org: PreviewOrg = { + apiKey: data.api_key, + orgId: data.org_id, + orgSlug: data.org_slug, + }; + console.log("[Preview] Setup complete:", { + orgId: org.orgId, + orgSlug: org.orgSlug, + }); + setPreviewOrg(org); + return org; + } catch (error) { + console.error("[Preview] Setup error:", error); + return null; + } + })(); + + previewSetupRef.current = setupPromise; + return setupPromise; + }, []); + + /** Sync pricing config to the preview org */ + const syncPreviewPricing = useCallback( + async (config: AgentPricingConfig) => { + // Prevent concurrent syncs + if (syncInProgressRef.current) { + console.log("[Preview] Sync already in progress, skipping..."); + return; + } + + // Ensure preview org is set up + let org = previewOrg; + if (!org) { + org = await setupPreviewOrg(); + if (!org) { + console.error("[Preview] Cannot sync - preview org not available"); + return; + } + } + + syncInProgressRef.current = true; + setIsPreviewSyncing(true); + try { + console.log("[Preview] Syncing pricing config..."); + console.log("[Preview] Features:", config.features.length); + console.log("[Preview] Products:", config.products.length); + + const response = await axiosInstance.post( + "/pricing-agent/preview/sync", + { + features: config.features, + products: config.products, + }, + ); + + console.log("[Preview] Sync complete:", response.data); + } catch (error) { + console.error("[Preview] Sync error:", error); + } finally { + syncInProgressRef.current = false; + setIsPreviewSyncing(false); + } + }, + [axiosInstance, previewOrg, setupPreviewOrg], + ); + + // Sync initial config on mount if provided + useEffect(() => { + if (options?.initialConfig && !initialSyncDoneRef.current) { + initialSyncDoneRef.current = true; + setupPreviewOrg().then((org) => { + if (org) { + syncPreviewPricing(options.initialConfig as AgentPricingConfig); + } + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const { messages, sendMessage, status, addToolOutput, setMessages } = useChat({ + transport: new DefaultChatTransport({ + api: `${import.meta.env.VITE_BACKEND_URL}/pricing-agent/chat`, + credentials: "include", + headers: { + "x-client-type": "dashboard", + }, + body: { + sessionId: chatSessionIdRef.current, + initialConfig: options?.initialConfig ?? null, + }, + }), + + // Auto-submit when all tool results are available (for multi-step if needed) + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls, + + // Handle client-side tool execution + onToolCall: async ({ toolCall }) => { + // Check for dynamic tools first + if (toolCall.dynamic) { + return; + } + + if (toolCall.toolName === "build_pricing") { + const config = toolCall.input as AgentPricingConfig; + + // Update the pricing preview + setPricingConfig(config); + + // Sync to preview org (fire and forget) + syncPreviewPricing(config); + + // Return the tool result (no await to avoid deadlocks) + addToolOutput({ + tool: "build_pricing", + toolCallId: toolCall.toolCallId, + output: { + success: true, + productsCount: config.products.length, + featuresCount: config.features.length, + }, + }); + } + }, + }); + + const handleSubmit = useCallback( + (message: PromptInputMessage) => { + if ( + (!message.text.trim() && message.files.length === 0) || + status !== "ready" + ) + return; + + setHasStartedChat(true); + sendMessage({ + text: message.text, + files: message.files, + }); + setInput(""); + }, + [status, sendMessage], + ); + + const handleStartNewChat = useCallback(() => { + setHasStartedChat(false); + setInput(""); + setPricingConfig(null); + setMessages([]); + }, [setMessages]); + + const isLoading = status === "streaming" || status === "submitted"; + + return { + // Chat state + messages, + input, + setInput, + status, + isLoading, + hasStartedChat, + setHasStartedChat, + + // Pricing config + pricingConfig, + setPricingConfig, + + // Preview org + previewOrg, + isPreviewSyncing, + + // JSON sheet + jsonSheetConfig, + setJsonSheetConfig, + + // Actions + handleSubmit, + handleStartNewChat, + }; +} diff --git a/vite/src/views/onboarding4/preview/GroupedPlanCards.tsx b/vite/src/views/onboarding4/preview/GroupedPlanCards.tsx new file mode 100644 index 000000000..0c9620d32 --- /dev/null +++ b/vite/src/views/onboarding4/preview/GroupedPlanCards.tsx @@ -0,0 +1,49 @@ +import { SectionTag } from "@/components/v2/badges/SectionTag"; +import { PreviewPlanCard } from "./PreviewPlanCard"; +import { groupPreviewProducts, type PreviewProduct } from "./previewTypes"; + +interface GroupedPlanCardsProps { + products: PreviewProduct[]; + previewApiKey?: string; + isSyncing: boolean; + changedProductIds: Set; +} + +export function GroupedPlanCards({ + products, + previewApiKey, + isSyncing, + changedProductIds, +}: GroupedPlanCardsProps) { + const { subscriptions, addOnSubscriptions, oneTimePlans } = + groupPreviewProducts(products); + + const renderSection = (title: string, items: PreviewProduct[]) => { + if (items.length === 0) return null; + + return ( +
+ {title} +
+ {items.map((product) => ( + + ))} +
+
+ ); + }; + + return ( + <> + {renderSection("Subscriptions", subscriptions)} + {renderSection("Add-on subscriptions", addOnSubscriptions)} + {renderSection("One-off purchases", oneTimePlans)} + + ); +} diff --git a/vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx b/vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx index 333d09e1a..0da4db1f2 100644 --- a/vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx +++ b/vite/src/views/onboarding4/preview/PreviewCheckoutButton.tsx @@ -6,7 +6,7 @@ type CheckoutState = "idle" | "waiting_for_sync" | "creating_checkout"; interface PreviewCheckoutButtonProps { productId: string; - previewApiKey: string; + previewApiKey?: string; isSyncing: boolean; } @@ -18,6 +18,7 @@ export function PreviewCheckoutButton({ const [checkoutState, setCheckoutState] = useState("idle"); const createCheckout = useCallback(async () => { + if (!previewApiKey) return; setCheckoutState("creating_checkout"); try { console.log( @@ -70,6 +71,7 @@ export function PreviewCheckoutButton({ }, [checkoutState, isSyncing, createCheckout]); const handleClick = () => { + if (!previewApiKey) return; if (isSyncing) { // Wait for sync to complete setCheckoutState("waiting_for_sync"); @@ -80,6 +82,7 @@ export function PreviewCheckoutButton({ }; const isLoading = checkoutState !== "idle"; + const isDisabled = isLoading || !previewApiKey; const getButtonText = () => { switch (checkoutState) { @@ -98,7 +101,7 @@ export function PreviewCheckoutButton({ size="sm" className="w-full mt-auto" onClick={handleClick} - disabled={isLoading} + disabled={isDisabled} > {isLoading ? ( <> diff --git a/vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx b/vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx index 7b9efb5c7..3aefbe3c2 100644 --- a/vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx +++ b/vite/src/views/onboarding4/preview/PreviewCreditSchemaCard.tsx @@ -1,3 +1,4 @@ +import type { AgentFeature } from "@autumn/shared"; import { Coins } from "lucide-react"; import { Card, @@ -5,11 +6,12 @@ import { CardHeader, CardTitle, } from "@/components/v2/cards/Card"; -import type { AgentFeature } from "../pricingAgentUtils"; +import { cn } from "@/lib/utils"; interface PreviewCreditSchemaCardProps { creditFeature: AgentFeature; allFeatures: AgentFeature[]; + isChanged?: boolean; } /** @@ -18,6 +20,7 @@ interface PreviewCreditSchemaCardProps { export function PreviewCreditSchemaCard({ creditFeature, allFeatures, + isChanged = false, }: PreviewCreditSchemaCardProps) { const creditSchema = creditFeature.credit_schema; @@ -30,7 +33,13 @@ export function PreviewCreditSchemaCard({ const creditSingular = creditFeature.display?.singular ?? "credit"; return ( - +
diff --git a/vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx b/vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx index 48e7c69c8..d523bae5a 100644 --- a/vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx +++ b/vite/src/views/onboarding4/preview/PreviewFeatureIcon.tsx @@ -1,10 +1,6 @@ import { - BatteryHighIcon, BoxArrowDownIcon, - CoinsIcon, MoneyWavyIcon, - TicketIcon, - ToggleRightIcon, WalletIcon, } from "@phosphor-icons/react"; import type React from "react"; @@ -13,7 +9,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/v2/tooltips/Tooltip"; -import type { AgentFeature } from "../pricingAgentUtils"; +import { getFeatureIconConfig } from "@/views/products/features/utils/getFeatureIcon"; import type { PreviewProductItem } from "./previewTypes"; interface PreviewFeatureIconProps { @@ -22,53 +18,8 @@ interface PreviewFeatureIconProps { size?: number; } -type FeatureTypeKey = AgentFeature["type"]; type BillingType = "included" | "prepaid" | "paid"; -/** - * Get icon for feature type (left position) - */ -function getFeatureTypeIcon({ - featureType, - size, -}: { - featureType: FeatureTypeKey; - size: number; -}): { icon: React.ReactNode; color: string; label: string } { - const weight = "duotone"; - - switch (featureType) { - case "boolean": - case "static": - return { - icon: , - color: "text-red-500", - label: "Boolean", - }; - - case "credit_system": - return { - icon: , - color: "text-pink-500", - label: "Credit System", - }; - - case "continuous_use": - return { - icon: , - color: "text-blue-500", - label: "Non-consumable", - }; - - default: - return { - icon: , - color: "text-fuchsia-500", - label: "Consumable", - }; - } -} - /** * Determine billing type from item properties */ @@ -130,7 +81,7 @@ export function PreviewFeatureIcon({ }: PreviewFeatureIconProps) { const iconData = position === "left" - ? getFeatureTypeIcon({ featureType: item.featureType, size }) + ? getFeatureIconConfig(item.featureType, null, size) : getBillingTypeIcon({ billingType: getBillingType(item), size }); return ( diff --git a/vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx b/vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx index f9fce78ba..cba574dad 100644 --- a/vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx +++ b/vite/src/views/onboarding4/preview/PreviewFeatureRow.tsx @@ -11,15 +11,13 @@ function DotIcon() { } export function PreviewFeatureRow({ item }: PreviewFeatureRowProps) { - // Use column layout only if item has both pricing AND included usage - const hasPricing = item.price != null && item.price > 0; - const hasIncludedUsage = - item.includedUsage != null && - (item.includedUsage === "inf" || item.includedUsage > 0); - const useColumnLayout = hasPricing && hasIncludedUsage; + // Use column layout when secondary text starts with "then" (indicates pricing with included usage) + // This handles both flat price and tiered pricing scenarios + const useColumnLayout = + item.display.secondaryText?.startsWith("then") ?? false; return ( -
+
@@ -35,7 +33,7 @@ export function PreviewFeatureRow({ item }: PreviewFeatureRowProps) {
{item.display.secondaryText && ( - + {item.display.secondaryText} )} diff --git a/vite/src/views/onboarding4/preview/PreviewPlanCard.tsx b/vite/src/views/onboarding4/preview/PreviewPlanCard.tsx index 78b8e535f..4478b6125 100644 --- a/vite/src/views/onboarding4/preview/PreviewPlanCard.tsx +++ b/vite/src/views/onboarding4/preview/PreviewPlanCard.tsx @@ -4,6 +4,7 @@ import { CardFooter, CardHeader, } from "@/components/v2/cards/Card"; +import { cn } from "@/lib/utils"; import { PreviewCheckoutButton } from "./PreviewCheckoutButton"; import { PreviewFeatureRow } from "./PreviewFeatureRow"; import { PreviewPlanHeader } from "./PreviewPlanHeader"; @@ -13,15 +14,23 @@ interface PreviewPlanCardProps { product: PreviewProduct; previewApiKey?: string; isSyncing: boolean; + isChanged?: boolean; } export function PreviewPlanCard({ product, previewApiKey, isSyncing, + isChanged = false, }: PreviewPlanCardProps) { return ( - + @@ -39,7 +48,7 @@ export function PreviewPlanCard({ )} - {previewApiKey && product.basePrice.type !== "free" && ( + {product.basePrice.type !== "free" && ( {/* Name row with badges */}
- + {product.name} ({ + ...item, + interval: item.interval ?? null, + })); + const isOneOff = isOneOffProductV2({ items: normalizedItems }); + // Transform feature items const featureItems = (product.items ?? []) .filter((item) => item.feature_id) @@ -81,6 +89,7 @@ export function transformToPreviewProducts({ name: product.name, isAddOn: product.is_add_on, isDefault: product.is_default, + isOneOff, basePrice, items: featureItems, freeTrial: product.free_trial @@ -93,88 +102,6 @@ export function transformToPreviewProducts({ }); } -/** - * Convert AgentProduct to ProductV2 for use with shared utilities - */ -function agentProductToProductV2(product: AgentProduct): ProductV2 { - const items: ProductItem[] = (product.items ?? []).map( - agentItemToProductItem, - ); - - return { - internal_id: product.id, - id: product.id, - name: product.name, - description: null, - is_add_on: product.is_add_on ?? false, - is_default: product.is_default ?? false, - version: 1, - group: product.group ?? null, - env: AppEnv.Sandbox, - free_trial: null, // Free trial display handled separately in PreviewProduct - items, - created_at: Date.now(), - }; -} - -/** - * Map AgentFeature type string to FeatureType enum - */ -function mapFeatureType(agentType: AgentFeature["type"]): FeatureType { - switch (agentType) { - case "boolean": - case "static": - return FeatureType.Boolean; - case "credit_system": - return FeatureType.CreditSystem; - default: - return FeatureType.Metered; - } -} - -/** - * Convert AgentFeature to shared Feature type - */ -function agentFeatureToFeature(agentFeature: AgentFeature): Feature { - return { - internal_id: agentFeature.id, - org_id: "", - created_at: Date.now(), - env: AppEnv.Sandbox, - id: agentFeature.id, - name: agentFeature.name ?? agentFeature.display?.plural ?? agentFeature.id, - type: mapFeatureType(agentFeature.type), - config: null, - display: agentFeature.display - ? { - singular: agentFeature.display.singular, - plural: agentFeature.display.plural, - } - : undefined, - archived: false, - event_names: [], - }; -} - -/** - * Convert AgentProductItem to shared ProductItem type - */ -function agentItemToProductItem(item: AgentProductItem): ProductItem { - return { - feature_id: item.feature_id, - included_usage: - item.included_usage === "inf" - ? Infinite - : (item.included_usage ?? undefined), - interval: item.interval as ProductItem["interval"], - price: item.price, - billing_units: item.billing_units, - usage_model: item.usage_model as ProductItem["usage_model"], - tiers: - item.price != null ? [{ to: Infinite, amount: item.price }] : undefined, - }; -} - function transformToPreviewItem({ item, features, @@ -217,3 +144,248 @@ function transformToPreviewItem({ }, }; } + +// ============ CHANGE DETECTION ============ + +/** Normalize falsy values for comparison (undefined, null, false all become false) */ +function normalizeBool(val: boolean | undefined | null): boolean { + return val === true; +} + +/** Normalize optional strings (undefined, null, and empty string all become null) */ +function normalizeStr(val: string | undefined | null): string | null { + return val || null; +} + +/** Normalize optional numbers */ +function normalizeNum( + val: number | "inf" | undefined | null, +): number | "inf" | null { + return val ?? null; +} + +function areItemsEqual( + items1: AgentProductItem[], + items2: AgentProductItem[], +): boolean { + if (items1.length !== items2.length) return false; + + return items1.every((item1, index) => { + const item2 = items2[index]; + return ( + normalizeStr(item1.feature_id) === normalizeStr(item2.feature_id) && + normalizeNum(item1.included_usage) === + normalizeNum(item2.included_usage) && + normalizeStr(item1.interval) === normalizeStr(item2.interval) && + normalizeNum(item1.price) === normalizeNum(item2.price) && + normalizeStr(item1.usage_model) === normalizeStr(item2.usage_model) && + normalizeNum(item1.billing_units) === normalizeNum(item2.billing_units) && + JSON.stringify(item1.tiers ?? null) === + JSON.stringify(item2.tiers ?? null) + ); + }); +} + +function areProductsEqual( + product1: AgentProduct, + product2: AgentProduct, +): boolean { + if ( + product1.name !== product2.name || + normalizeBool(product1.is_add_on) !== normalizeBool(product2.is_add_on) || + normalizeBool(product1.is_default) !== normalizeBool(product2.is_default) || + normalizeStr(product1.group) !== normalizeStr(product2.group) + ) { + return false; + } + + // Compare free trial (both falsy = equal) + const ft1 = product1.free_trial; + const ft2 = product2.free_trial; + const hasFt1 = ft1 != null; + const hasFt2 = ft2 != null; + if (hasFt1 !== hasFt2) return false; + if (ft1 && ft2) { + if (ft1.length !== ft2.length || ft1.duration !== ft2.duration) { + return false; + } + } + + // Compare items + const items1 = product1.items ?? []; + const items2 = product2.items ?? []; + return areItemsEqual(items1, items2); +} + +/** Returns a Set of product IDs that have changed from the initial config */ +export function getChangedProductIds({ + initialConfig, + currentConfig, +}: { + initialConfig: AgentPricingConfig | null; + currentConfig: AgentPricingConfig | null; +}): Set { + const changedIds = new Set(); + + if (!initialConfig || !currentConfig) { + return changedIds; + } + + const initialProductMap = new Map( + initialConfig.products.map((p) => [p.id, p]), + ); + + for (const currentProduct of currentConfig.products) { + const initialProduct = initialProductMap.get(currentProduct.id); + + // New product (wasn't in initial config) + if (!initialProduct) { + changedIds.add(currentProduct.id); + continue; + } + + // Existing product - check if changed + if (!areProductsEqual(initialProduct, currentProduct)) { + changedIds.add(currentProduct.id); + } + } + + return changedIds; +} + +function areFreeTrialsEqual( + ft1: AgentProduct["free_trial"], + ft2: AgentProduct["free_trial"], +): boolean { + if (!ft1 && !ft2) return true; + if (!ft1 || !ft2) return false; + return ft1.length === ft2.length && ft1.duration === ft2.duration; +} + +/** Returns product IDs that will create new versions (existing + customers + billing changes) */ +export function getVersionedProductIds({ + initialConfig, + currentConfig, + productCounts, +}: { + initialConfig: AgentPricingConfig | null; + currentConfig: AgentPricingConfig | null; + productCounts: Record; +}): string[] { + if (!initialConfig || !currentConfig) return []; + + const initialProductMap = new Map( + initialConfig.products.map((p) => [p.id, p]), + ); + + return currentConfig.products + .filter((currentProduct) => { + const initialProduct = initialProductMap.get(currentProduct.id); + if (!initialProduct) return false; + + const customerCount = productCounts[currentProduct.id]?.all ?? 0; + if (customerCount === 0) return false; + + const itemsChanged = !areItemsEqual( + initialProduct.items ?? [], + currentProduct.items ?? [], + ); + const freeTrialChanged = !areFreeTrialsEqual( + initialProduct.free_trial, + currentProduct.free_trial, + ); + + return itemsChanged || freeTrialChanged; + }) + .map((product) => product.id); +} + +/** Check if two credit system features are equal */ +function areFeaturesEqual( + feature1: AgentFeature, + feature2: AgentFeature, +): boolean { + if (feature1.name !== feature2.name || feature1.type !== feature2.type) { + return false; + } + + // Compare display - only if BOTH features have display defined + // The display field is cosmetic (UI labels) and doesn't affect billing. + // If one has display and the other doesn't, it's not a meaningful change. + const d1 = feature1.display; + const d2 = feature2.display; + if (d1 && d2) { + if (d1.singular !== d2.singular || d1.plural !== d2.plural) { + return false; + } + } + + // Compare credit schema + const cs1 = feature1.credit_schema ?? []; + const cs2 = feature2.credit_schema ?? []; + if (cs1.length !== cs2.length) return false; + return cs1.every((s1, index) => { + const s2 = cs2[index]; + return ( + s1.metered_feature_id === s2.metered_feature_id && + s1.credit_cost === s2.credit_cost + ); + }); +} + +/** Returns a Set of feature IDs that have changed from the initial config */ +export function getChangedFeatureIds({ + initialConfig, + currentConfig, +}: { + initialConfig: AgentPricingConfig | null; + currentConfig: AgentPricingConfig | null; +}): Set { + const changedIds = new Set(); + + if (!initialConfig || !currentConfig) { + return changedIds; + } + + const initialFeatureMap = new Map( + initialConfig.features.map((f) => [f.id, f]), + ); + + for (const currentFeature of currentConfig.features) { + const initialFeature = initialFeatureMap.get(currentFeature.id); + + // New feature (wasn't in initial config) + if (!initialFeature) { + changedIds.add(currentFeature.id); + continue; + } + + // Existing feature - check if changed + if (!areFeaturesEqual(initialFeature, currentFeature)) { + changedIds.add(currentFeature.id); + } + } + + return changedIds; +} + +// ============ PRODUCT GROUPING ============ + +export interface GroupedPreviewProducts { + subscriptions: PreviewProduct[]; + addOnSubscriptions: PreviewProduct[]; + oneTimePlans: PreviewProduct[]; +} + +/** Groups products into subscriptions, add-on subscriptions, and one-time plans (mirrors ProductListTable logic) */ +export function groupPreviewProducts( + products: PreviewProduct[], +): GroupedPreviewProducts { + const oneTimePlans = products.filter((p) => p.isOneOff); + const recurringPlans = products.filter((p) => !p.isOneOff); + + const subscriptions = recurringPlans.filter((p) => !p.isAddOn); + const addOnSubscriptions = recurringPlans.filter((p) => p.isAddOn); + + return { subscriptions, addOnSubscriptions, oneTimePlans }; +} diff --git a/vite/src/views/onboarding4/pricingAgentUtils.ts b/vite/src/views/onboarding4/pricingAgentUtils.ts deleted file mode 100644 index f677ae754..000000000 --- a/vite/src/views/onboarding4/pricingAgentUtils.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { PricingTier } from "./templateConfigs"; - -/** - * Types for the pricing config returned by the AI agent's build_pricing tool - */ -export interface AgentFeature { - id: string; - name?: string | null; - type: - | "static" - | "boolean" - | "single_use" - | "continuous_use" - | "credit_system"; - display?: { - singular: string; - plural: string; - } | null; - credit_schema?: Array<{ - metered_feature_id: string; - credit_cost: number; - }> | null; -} - -export interface AgentProductItem { - feature_id?: string | null; - included_usage?: number | "inf" | null; - interval?: string | null; - price?: number | null; - usage_model?: "prepaid" | "pay_per_use" | null; - billing_units?: number | null; -} - -export interface AgentFreeTrial { - length: number; - duration: "day" | "month" | "year"; - unique_fingerprint?: boolean; - card_required?: boolean; -} - -export interface AgentProduct { - id: string; - name: string; - is_add_on?: boolean; - is_default?: boolean; - group?: string; - items?: AgentProductItem[]; - free_trial?: AgentFreeTrial | null; -} - -export interface AgentPricingConfig { - features: AgentFeature[]; - products: AgentProduct[]; -} - -/** - * Transform an AgentPricingConfig (from the AI) into PricingTier[] (for the UI) - */ -function transformConfigToTiers({ - config, - features, -}: { - config: AgentPricingConfig; - features: AgentFeature[]; -}): PricingTier[] { - return config.products.map((product) => { - // Find the base price (item without feature_id, or first priced item) - const basePrice = product.items?.find( - (item) => !item.feature_id && item.price != null, - ); - const fixedPrice = basePrice?.price ?? 0; - const interval = basePrice?.interval ?? "month"; - - // Determine price display - let priceDisplay: string; - if ( - fixedPrice === 0 && - !product.items?.some((i) => i.price && i.price > 0) - ) { - priceDisplay = "Free"; - } else if (fixedPrice > 0) { - priceDisplay = `$${fixedPrice}`; - } else { - // Usage-based only - priceDisplay = "Usage-based"; - } - - // Build feature list for the card - const featureList: string[] = []; - - for (const item of product.items ?? []) { - if (item.feature_id) { - const feature = features.find((f) => f.id === item.feature_id); - const featureName = - feature?.name ?? feature?.display?.plural ?? item.feature_id; - - if (item.included_usage === "inf") { - featureList.push(`Unlimited ${featureName}`); - } else if (item.included_usage != null && item.included_usage > 0) { - featureList.push( - `${item.included_usage.toLocaleString()} ${featureName}`, - ); - } else if (item.price != null && item.price > 0) { - featureList.push( - `${featureName} at $${item.price}${item.billing_units ? `/${item.billing_units}` : "/unit"}`, - ); - } - } else if (item.price != null && item.price > 0 && !basePrice) { - // It's a standalone price item - featureList.push(`$${item.price}/${item.interval ?? "month"} base`); - } - } - - // Add free trial info if present - if (product.free_trial) { - featureList.push( - `${product.free_trial.length} ${product.free_trial.duration} free trial`, - ); - } - - // Determine if this tier should be highlighted - // Typically the "Pro" or middle tier, or explicitly named - const isHighlighted = - product.name.toLowerCase().includes("pro") || - product.name.toLowerCase().includes("plus") || - product.name.toLowerCase().includes("premium"); - - return { - name: product.name, - price: priceDisplay, - interval: fixedPrice > 0 ? interval : undefined, - description: product.is_add_on ? "Add-on" : undefined, - features: featureList.length > 0 ? featureList : ["Basic features"], - highlighted: isHighlighted, - }; - }); -} diff --git a/vite/src/views/onboarding4/utils/convertToAgentConfig.ts b/vite/src/views/onboarding4/utils/convertToAgentConfig.ts new file mode 100644 index 000000000..6eb90e70e --- /dev/null +++ b/vite/src/views/onboarding4/utils/convertToAgentConfig.ts @@ -0,0 +1,5 @@ +/** + * Re-export convertToAgentConfig from shared for backwards compatibility. + * The actual implementation now lives in @autumn/shared. + */ +export { convertToAgentConfig } from "@autumn/shared"; 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 a5257c64f..aaa7c7ce4 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 @@ -7,11 +7,7 @@ import { ProductItemInterval, productV2ToBasePrice, } from "@autumn/shared"; -import { - ArrowsClockwiseIcon, - BarcodeIcon, - CheckCircleIcon, -} from "@phosphor-icons/react"; +import { ArrowsClockwiseIcon, CheckCircleIcon } from "@phosphor-icons/react"; import { GroupedTabButton } from "@/components/v2/buttons/GroupedTabButton"; import { FormLabel } from "@/components/v2/form/FormLabel"; import { @@ -23,6 +19,8 @@ import { InputGroupAddon, InputGroupInput, } from "@/components/v2/inputs/InputGroup"; +import { AreaRadioGroupItem } from "@/components/v2/radio-groups/AreaRadioGroupItem"; +import { RadioGroup } from "@/components/v2/radio-groups/RadioGroup"; import { SheetSection } from "@/components/v2/sheets/InlineSheet"; import { useOrg } from "@/hooks/common/useOrg"; import { InfoBox } from "@/views/onboarding2/integrate/components/InfoBox"; @@ -106,95 +104,148 @@ export const BasePriceSection = ({ const disabled = nullish(basePrice); + // Determine if we're in "usage" (per unit only) mode or "base price" mode + const isPerUnitOnly = basePriceType === "usage"; + + // Get the billing type (recurring or one-off) - default to recurring + const billingType = + basePriceType === "usage" + ? "recurring" + : basePriceType === "one-off" + ? "one-off" + : "recurring"; + + const handleBillingTypeChange = (value: string) => { + // Only change billing type if we're in base price mode + if (isPerUnitOnly) return; + + // Check if there's already a price item + const hasPriceItem = product.items.some((item) => isPriceItem(item)); + + if (!hasPriceItem) { + // Recreate the price item with default price of 0 + const newPriceItem: ProductItem = { + price: "" as unknown as number, + interval: value === "one-off" ? null : ProductItemInterval.Month, + interval_count: 1, + }; + + setProduct({ + ...product, + basePriceType: value as "recurring" | "one-off", + items: [...product.items, newPriceItem], + }); + return; + } + + // Update existing price item + setProduct({ + ...product, + basePriceType: value as "recurring" | "one-off", + items: product.items.map((item) => { + if (isPriceItem(item)) { + return { + ...item, + interval: value === "one-off" ? null : ProductItemInterval.Month, + }; + } + return item; + }), + }); + }; + + const handlePriceTypeChange = (value: string) => { + if (value === "usage") { + // Switch to per unit only - remove base price item + setProduct({ + ...product, + basePriceType: "usage", + items: product.items.filter((item) => !isPriceItem(item)), + }); + } else { + // Switch to base price - restore price item with current billing type + const hasPriceItem = product.items.some((item) => isPriceItem(item)); + + if (!hasPriceItem) { + const newPriceItem: ProductItem = { + price: "" as unknown as number, + interval: + billingType === "one-off" ? null : ProductItemInterval.Month, + interval_count: 1, + }; + + setProduct({ + ...product, + basePriceType: billingType as "recurring" | "one-off", + items: [...product.items, newPriceItem], + }); + } else { + setProduct({ + ...product, + basePriceType: billingType as "recurring" | "one-off", + }); + } + } + }; + return ( - -
-
- { - //if usage based, remove the base price item - if (value === "usage") { - setProduct({ - ...product, - basePriceType: "usage", - items: product.items.filter((item) => !isPriceItem(item)), - }); - return; - } - - // Check if there's already a price item - const hasPriceItem = product.items.some((item) => - isPriceItem(item), - ); - - if (!hasPriceItem) { - // Recreate the price item with default price of 0 - const newPriceItem: ProductItem = { - price: "" as unknown as number, - interval: - value === "one-off" ? null : ProductItemInterval.Month, - interval_count: 1, - }; - - setProduct({ - ...product, - basePriceType: value as "recurring" | "one-off" | "usage", - items: [...product.items, newPriceItem], - }); - return; - } - - // Update existing price item - setProduct({ - ...product, - basePriceType: value as "recurring" | "one-off" | "usage", - items: product.items.map((item) => { - if (isPriceItem(item)) { - return { - ...item, - interval: - value === "one-off" ? null : ProductItemInterval.Month, - }; - } - return item; - }), - }); - }} - options={[ - { - value: "recurring", - label: "Recurring", - icon: ( - - ), - }, - { - value: "one-off", - label: "One-off", - icon: ( - - ), - }, - { - value: "usage", - label: "Per unit only", - icon: , - }, - ]} - /> - {/*

{priceDescription}

*/} + +
+
+ + + +
-
- {basePriceType !== "usage" ? ( + + {isPerUnitOnly ? ( + + This plan has no base price. You can add per unit prices, such as + per "seat" or "credit", when adding features. + + ) : ( + <> +
+ + ), + }, + { + value: "one-off", + label: "One-off", + icon: ( + + ), + }, + ]} + /> +
Price @@ -225,24 +276,19 @@ export const BasePriceSection = ({
- {basePriceType === "recurring" && ( + {billingType === "recurring" && (
)}
- ) : ( - - This plan has no base price. You can add per unit prices, such as - per "seat" or "credit", when adding features. - - )} -
+ + )}
); diff --git a/vite/src/views/products/product/utils/updateProduct.ts b/vite/src/views/products/product/utils/updateProduct.ts index 70f3912a8..64a311fbc 100644 --- a/vite/src/views/products/product/utils/updateProduct.ts +++ b/vite/src/views/products/product/utils/updateProduct.ts @@ -1,6 +1,6 @@ import { type FrontendProductItem, - type ProductV2, + type UpdateProductV2Params, UpdateProductV2ParamsSchema, } from "@autumn/shared"; import type { AxiosError, AxiosInstance } from "axios"; @@ -18,7 +18,7 @@ export const updateProduct = async ({ }: { axiosInstance: AxiosInstance; productId: string; - product: ProductV2; + product: UpdateProductV2Params; onSuccess: () => Promise; }) => { const validated = validateItemsBeforeSave( diff --git a/vite/src/views/products/products/ProductsPage.tsx b/vite/src/views/products/products/ProductsPage.tsx index 525b6e9bd..c5da10c95 100644 --- a/vite/src/views/products/products/ProductsPage.tsx +++ b/vite/src/views/products/products/ProductsPage.tsx @@ -1,13 +1,32 @@ +import { useState } from "react"; import { useClearQueryParams } from "@/hooks/common/useClearQueryParams"; +import { ProductsAIChatView } from "./components/ProductsAIChatView"; +import { ProductsPageHeader } from "./components/ProductsPageHeader"; +import { + type ProductsViewMode, + ProductsViewToggle, +} from "./components/ProductsViewToggle"; +import { ProductListCreateButton } from "./components/product-list/ProductListCreateButton"; +import { ProductListMenuButton } from "./components/product-list/ProductListMenuButton"; import { ProductListTable } from "./components/product-list/ProductListTable"; export const ProductsPage = () => { // Clean up onboarding-related query params after a delay useClearQueryParams({ queryParams: ["step", "product_id"] }); + const [viewMode, setViewMode] = useState("list"); + return (
- + {/* Shared header - always visible */} + + + + + + + {/* Conditional content */} + {viewMode === "list" ? : }
); }; diff --git a/vite/src/views/products/products/components/ConfirmBatchVersionDialog.tsx b/vite/src/views/products/products/components/ConfirmBatchVersionDialog.tsx new file mode 100644 index 000000000..dc5e2fdb2 --- /dev/null +++ b/vite/src/views/products/products/components/ConfirmBatchVersionDialog.tsx @@ -0,0 +1,97 @@ +import { useState } from "react"; +import { Button } from "@/components/v2/buttons/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/v2/dialogs/Dialog"; +import { Input } from "@/components/v2/inputs/Input"; + +const CONFIRM_TEXT = "confirm"; + +interface ConfirmBatchVersionDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + productIds: string[]; + onConfirm: () => Promise; +} + +export function ConfirmBatchVersionDialog({ + open, + onOpenChange, + productIds, + onConfirm, +}: ConfirmBatchVersionDialogProps) { + const [confirmText, setConfirmText] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const isValid = confirmText.trim() === CONFIRM_TEXT; + + const handleConfirm = async () => { + if (!isValid) return; + setIsLoading(true); + try { + await onConfirm(); + onOpenChange(false); + } finally { + setIsLoading(false); + setConfirmText(""); + } + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + setConfirmText(""); + } + onOpenChange(nextOpen); + }; + + return ( + + + + Create new versions? + +

+ The following plans have active customers and will create{" "} + new versions: +

+
    + {productIds.map((productId) => ( +
  • + {productId} +
  • + ))} +
+

+ Type {CONFIRM_TEXT} to continue. +

+ setConfirmText(e.target.value)} + type="text" + placeholder={CONFIRM_TEXT} + className="w-full" + /> +
+
+ + + + +
+
+ ); +} diff --git a/vite/src/views/products/products/components/ProductsAIChatView.tsx b/vite/src/views/products/products/components/ProductsAIChatView.tsx new file mode 100644 index 000000000..19bc2472f --- /dev/null +++ b/vite/src/views/products/products/components/ProductsAIChatView.tsx @@ -0,0 +1,150 @@ +import { useMemo } from "react"; +import { CompactPromptInput } from "@/components/ai-elements/CompactPromptInput"; +import { Button } from "@/components/v2/buttons/Button"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { PricingChatPanel } from "@/views/onboarding4/components/PricingChatPanel"; +import { usePricingAgentChat } from "@/views/onboarding4/hooks/usePricingAgentChat"; +import { PricingConfigSheet } from "@/views/onboarding4/PricingConfigSheet"; +import { PricingPreview } from "@/views/onboarding4/PricingPreview"; +import { convertToAgentConfig } from "@/views/onboarding4/utils/convertToAgentConfig"; +import { usePricingConfigSave } from "../hooks/usePricingConfigSave"; +import { ConfirmBatchVersionDialog } from "./ConfirmBatchVersionDialog"; + +/** + * AI chat view for the products page. + * Similar to the onboarding AIChatView but starts with existing plans loaded. + */ +export function ProductsAIChatView() { + const { products } = useProductsQuery(); + const { features } = useFeaturesQuery(); + + // Filter out archived products + const activeProducts = useMemo( + () => products.filter((p) => !p.archived), + [products], + ); + + // Convert existing products/features to initial config + const initialConfig = useMemo(() => { + if (activeProducts.length === 0 && features.length === 0) { + return null; + } + return convertToAgentConfig({ products: activeProducts, features }); + }, [activeProducts, features]); + + const { + messages, + input, + setInput, + isLoading, + pricingConfig, + previewOrg, + isPreviewSyncing, + jsonSheetConfig, + setJsonSheetConfig, + handleSubmit, + handleStartNewChat, + } = usePricingAgentChat({ initialConfig }); + + const { + isSaving, + confirmOpen, + setConfirmOpen, + versionedProductIds, + handleSave, + handleConfirmSave, + } = usePricingConfigSave({ initialConfig }); + + // Track if chat has started (has any messages) + const chatStarted = messages.length > 0; + + // Use the AI-generated config if available, otherwise fall back to initial + const displayConfig = pricingConfig ?? initialConfig; + + return ( +
+
+ {/* Left: Chat - width animates from 0 */} +
+ {chatStarted && ( + + )} +
+ + {/* Right: Pricing Preview - shrinks as chat expands */} +
+ 0 && + chatStarted && ( + <> + + + + ) + } + /> + + {/* Floating input when chat hasn't started */} + {!chatStarted && ( +
+
+ handleSubmit({ text: input, files: [] })} + placeholder="Describe changes to your pricing..." + isLoading={isLoading} + /> +
+
+ )} +
+
+ + !open && setJsonSheetConfig(null)} + config={jsonSheetConfig} + /> + +
+ ); +} diff --git a/vite/src/views/products/products/components/ProductsPageHeader.tsx b/vite/src/views/products/products/components/ProductsPageHeader.tsx new file mode 100644 index 000000000..ee23d092c --- /dev/null +++ b/vite/src/views/products/products/components/ProductsPageHeader.tsx @@ -0,0 +1,24 @@ +import { CubeIcon } from "@phosphor-icons/react"; +import type { ReactNode } from "react"; + +interface ProductsPageHeaderProps { + children?: ReactNode; +} + +/** + * Shared header for the Products/Plans page. + * Matches Table.Toolbar + Table.Heading styles. + */ +export function ProductsPageHeader({ children }: ProductsPageHeaderProps) { + return ( +
+
+
+ + Plans +
+
{children}
+
+
+ ); +} diff --git a/vite/src/views/products/products/components/ProductsViewToggle.tsx b/vite/src/views/products/products/components/ProductsViewToggle.tsx new file mode 100644 index 000000000..be1db6966 --- /dev/null +++ b/vite/src/views/products/products/components/ProductsViewToggle.tsx @@ -0,0 +1,66 @@ +import { ListBulletsIcon, SquareHalfIcon } from "@phosphor-icons/react"; +import { cn } from "@/lib/utils"; + +export type ProductsViewMode = "list" | "ai"; + +interface ProductsViewToggleProps { + value: ProductsViewMode; + onValueChange: (value: ProductsViewMode) => void; + className?: string; +} + +/** + * Icon toggle to switch between list view and AI chat view + */ +export function ProductsViewToggle({ + value, + onValueChange, + className, +}: ProductsViewToggleProps) { + const options: Array<{ + value: ProductsViewMode; + icon: React.ReactNode; + title: string; + }> = [ + { + value: "list", + icon: , + title: "List view", + }, + { + value: "ai", + icon: , + title: "AI assistant", + }, + ]; + + return ( +
+ {options.map((option, index) => { + const isActive = value === option.value; + const isFirst = index === 0; + const isLast = index === options.length - 1; + + return ( + + ); + })} +
+ ); +} diff --git a/vite/src/views/products/products/components/product-list/ProductListTable.tsx b/vite/src/views/products/products/components/product-list/ProductListTable.tsx index ede408760..590c5de19 100644 --- a/vite/src/views/products/products/components/product-list/ProductListTable.tsx +++ b/vite/src/views/products/products/components/product-list/ProductListTable.tsx @@ -1,5 +1,4 @@ import { isOneOffProductV2, type ProductV2 } from "@autumn/shared"; -import { CubeIcon } from "@phosphor-icons/react"; import type { SortingState } from "@tanstack/react-table"; import { useMemo, useState } from "react"; import { Table } from "@/components/general/table"; @@ -11,7 +10,6 @@ import { useProductsQueryState } from "@/views/products/hooks/useProductsQuerySt import { useProductTable } from "@/views/products/hooks/useProductTable"; import { createProductListColumns } from "./ProductListColumns"; import { ProductListCreateButton } from "./ProductListCreateButton"; -import { ProductListMenuButton } from "./ProductListMenuButton"; type ProductWithCounts = ProductV2 & { active_count?: number; @@ -154,47 +152,31 @@ export function ProductListTable() {
{showTableStructure ? ( <> - {/* Recurring Plans Section */} -
- - -
- - - Recurring Plans - - -
-
- - -
-
-
-
-
- - {hasRecurringAddOns && Main Plans} - - - - - -
+ {/* Plans Section */} +
+ + + Subscriptions + + + + + + - {/* Recurring Add-ons (only shown if add-ons exist) */} + {/* Add-on Plans (only shown when add-ons exist) */} {hasRecurringAddOns && ( - Add-ons + Add-on subscriptions )} -
- {/* One-Time Plans Section (only shown if one-time plans exist) */} - {hasOneTimePlans && ( -
- - - - - One-off Plans - - - - - - - - -
- )} + {/* One-time Plans (always shown) */} + + + One-off purchases + + + + + +
) : ( } /> diff --git a/vite/src/views/products/products/hooks/usePricingConfigSave.ts b/vite/src/views/products/products/hooks/usePricingConfigSave.ts new file mode 100644 index 000000000..d5dfd50e2 --- /dev/null +++ b/vite/src/views/products/products/hooks/usePricingConfigSave.ts @@ -0,0 +1,147 @@ +import type { AgentPricingConfig, UpdateProductV2Params } from "@autumn/shared"; +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { toast } from "sonner"; +import { useOrg } from "@/hooks/common/useOrg"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; +import { getBackendErr, pushPage } from "@/utils/genUtils"; +import { + getChangedProductIds, + getVersionedProductIds, +} from "@/views/onboarding4/preview/previewTypes"; +import { updateProduct } from "@/views/products/product/utils/updateProduct"; + +export const usePricingConfigSave = ({ + initialConfig, +}: { + initialConfig: AgentPricingConfig | null; +}) => { + const axiosInstance = useAxiosInstance(); + const navigate = useNavigate(); + const { org, mutate: mutateOrg } = useOrg(); + const { + counts, + isCountsLoading, + refetch: refetchProducts, + } = useProductsQuery(); + + const [isSaving, setIsSaving] = useState(false); + const [confirmOpen, setConfirmOpen] = useState(false); + const [versionedProductIds, setVersionedProductIds] = useState([]); + const [pendingConfig, setPendingConfig] = useState( + null, + ); + + const executeSave = async ({ config }: { config: AgentPricingConfig }) => { + // Push new products/features (existing are skipped server-side) + await axiosInstance.post("/v1/configs/push", { + features: config.features, + products: config.products, + }); + + const changedIds = getChangedProductIds({ + initialConfig, + currentConfig: config, + }); + const existingIds = new Set(initialConfig?.products.map((p) => p.id) ?? []); + + for (const productId of changedIds) { + if (!existingIds.has(productId)) continue; + const agentProduct = config.products.find((p) => p.id === productId); + if (!agentProduct) continue; + + await updateProduct({ + axiosInstance, + productId, + product: { + id: agentProduct.id, + name: agentProduct.name, + is_add_on: agentProduct.is_add_on, + is_default: agentProduct.is_default, + group: agentProduct.group || null, // empty string → null + items: agentProduct.items ?? [], + free_trial: agentProduct.free_trial ?? null, + } satisfies UpdateProductV2Params, + onSuccess: async () => {}, + }); + } + }; + + const handleSave = async ({ + config, + }: { + config: AgentPricingConfig | null; + }) => { + if (!config) return; + if (isCountsLoading) { + toast.error("Plan counts are loading"); + return; + } + + setIsSaving(true); + try { + const versionedIds = getVersionedProductIds({ + initialConfig, + currentConfig: config, + productCounts: counts, + }); + + if (versionedIds.length > 0) { + setVersionedProductIds(versionedIds); + setPendingConfig(config); + setConfirmOpen(true); + return; + } + + await executeSave({ config }); + toast.success("Changes saved successfully"); + await refetchProducts(); + + if (!org?.onboarded) { + await axiosInstance.patch("/v1/organization", { onboarded: true }); + await mutateOrg(); + } + + pushPage({ path: "/products", navigate }); + } catch (error) { + console.error("Error saving changes:", error); + toast.error(getBackendErr(error, "Failed to save changes")); + } finally { + setIsSaving(false); + } + }; + + const handleConfirmSave = async () => { + if (!pendingConfig) return; + setIsSaving(true); + try { + await executeSave({ config: pendingConfig }); + toast.success("Changes saved successfully"); + await refetchProducts(); + + if (!org?.onboarded) { + await axiosInstance.patch("/v1/organization", { onboarded: true }); + await mutateOrg(); + } + + pushPage({ path: "/products", navigate }); + setConfirmOpen(false); + setPendingConfig(null); + } catch (error) { + console.error("Error saving changes:", error); + toast.error(getBackendErr(error, "Failed to save changes")); + } finally { + setIsSaving(false); + } + }; + + return { + isSaving, + confirmOpen, + setConfirmOpen, + versionedProductIds, + handleSave, + handleConfirmSave, + }; +};