added ai chat for existing pricing
This commit is contained in:
committed by
Charlie Lamb
parent
49b500b1b5
commit
9e074ead99
192
.cursor/debug.log
Normal file
192
.cursor/debug.log
Normal file
@@ -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"}
|
||||
@@ -99,23 +99,34 @@ export const handleSyncPreviewPricing = createRoute({
|
||||
features: [] as Awaited<ReturnType<typeof FeatureService.list>>,
|
||||
};
|
||||
|
||||
// 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<string>();
|
||||
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({
|
||||
|
||||
@@ -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<typeof OrganisationConfigurationSchema>;
|
||||
|
||||
// ============ 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<HonoEnv>();
|
||||
|
||||
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: {
|
||||
|
||||
262
server/src/internal/misc/pricingAgent/pricingAgentSchemas.ts
Normal file
262
server/src/internal/misc/pricingAgent/pricingAgentSchemas.ts
Normal file
@@ -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<string, typeof products>();
|
||||
|
||||
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<typeof OrganisationConfigurationSchema>;
|
||||
@@ -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";
|
||||
|
||||
311
shared/utils/agentTypes.ts
Normal file
311
shared/utils/agentTypes.ts
Normal file
@@ -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<string, unknown> = {};
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
64
vite/src/components/ai-elements/CompactPromptInput.tsx
Normal file
64
vite/src/components/ai-elements/CompactPromptInput.tsx
Normal file
@@ -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<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && value.trim() && !isLoading) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={cn("w-full", className)}>
|
||||
<div className="flex items-center gap-2 rounded-xl border bg-white dark:bg-card px-3 py-2 dark:border-white/15 shadow-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-t4"
|
||||
/>
|
||||
<InputGroupButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="icon-sm"
|
||||
disabled={!value.trim() || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<CornerDownLeftIcon className="size-4" />
|
||||
)}
|
||||
</InputGroupButton>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export function TableEmptyState({
|
||||
<SmallSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-t4 text-center w-full h-full items-center justify-center flex">
|
||||
<div className="text-t4 text-xs text-center w-full h-full items-center justify-center flex">
|
||||
{emptyStateChildren || emptyStateText}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const EmptyState = ({ text }: { text: string | React.ReactNode }) => {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-4 border-dashed border rounded-lg h-13 w-full bg-interactive-secondary dark:bg-transparent shadow-sm dark:shadow-none">
|
||||
<span className="text-sm text-t4">{text}</span>
|
||||
<span className="text-xs text-t4">{text}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<PromptInputButton
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
disabled={disabled}
|
||||
title="Add image"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
</PromptInputButton>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentsHeader() {
|
||||
const attachments = usePromptInputAttachments();
|
||||
|
||||
if (!attachments.files.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PromptInputHeader>
|
||||
<PromptInputAttachments className="p-0 pt-2">
|
||||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||||
</PromptInputAttachments>
|
||||
</PromptInputHeader>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<AgentPricingConfig | null>(
|
||||
null,
|
||||
);
|
||||
const [jsonSheetConfig, setJsonSheetConfig] =
|
||||
useState<AgentPricingConfig | null>(null);
|
||||
const [previewOrg, setPreviewOrg] = useState<PreviewOrg | null>(null);
|
||||
const [isPreviewSyncing, setIsPreviewSyncing] = useState(false);
|
||||
const previewSetupRef = useRef<Promise<PreviewOrg | null> | 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<PreviewOrg | null> => {
|
||||
// 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 (
|
||||
<SidebarContext.Provider value={{ expanded: true, setExpanded: () => {} }}>
|
||||
<div className="w-full h-full flex flex-col bg-background">
|
||||
@@ -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"
|
||||
>
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="px-6">
|
||||
{messages.map((message) => (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
{message.parts.map((part, partIndex) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<MessageResponse key={partIndex}>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
);
|
||||
|
||||
case "file": {
|
||||
const isImage =
|
||||
part.mediaType?.startsWith("image/");
|
||||
if (!isImage || !part.url) return null;
|
||||
return (
|
||||
<div
|
||||
key={partIndex}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-1.5 py-1 w-fit"
|
||||
>
|
||||
<img
|
||||
src={part.url}
|
||||
alt={part.filename || "Attached image"}
|
||||
className="w-90 rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "tool-build_pricing": {
|
||||
const toolPart = part as BuildPricingToolPart;
|
||||
return (
|
||||
<div
|
||||
key={toolPart.toolCallId}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
{toolPart.state === "input-streaming" ||
|
||||
toolPart.state === "input-available" ? (
|
||||
<Shimmer className="text-sm">
|
||||
Building pricing configuration
|
||||
</Shimmer>
|
||||
) : toolPart.state === "output-error" ? (
|
||||
<span className="text-t4">
|
||||
Error generating pricing
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-t4 font-normal">
|
||||
Generated{" "}
|
||||
{toolPart.input?.products.length ??
|
||||
0}{" "}
|
||||
product(s) and{" "}
|
||||
{toolPart.input?.features.length ??
|
||||
0}{" "}
|
||||
feature(s)
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setJsonSheetConfig(
|
||||
toolPart.input ?? null,
|
||||
)
|
||||
}
|
||||
className="text-t3 text-xs"
|
||||
>
|
||||
View JSON
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
))}
|
||||
{isLoading &&
|
||||
messages.length > 0 &&
|
||||
messages[messages.length - 1]?.role === "user" && (
|
||||
<div className="flex items-center gap-2 text-t3 text-sm">
|
||||
<Shimmer>Planning next steps</Shimmer>
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
<PromptInput
|
||||
onSubmit={handleSubmit}
|
||||
accept="image/*"
|
||||
multiple
|
||||
>
|
||||
<AttachmentsHeader />
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Describe your app's pricing"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="justify-between">
|
||||
<ImageUploadButton disabled={isLoading} />
|
||||
<PromptInputSubmit
|
||||
disabled={isLoading}
|
||||
variant="primary"
|
||||
/>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
<PricingChatPanel
|
||||
messages={messages}
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
onViewJson={setJsonSheetConfig}
|
||||
placeholder="Describe your app's pricing"
|
||||
className="flex-1"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Right: Pricing Preview */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div className="relative rounded-xl border bg-interactive-secondary p-4 shadow-sm">
|
||||
<div className="relative rounded-xl bg-t8/10 p-4 border border-t8/20">
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -310,7 +310,7 @@ export function OnboardingGuide() {
|
||||
to learn more about what you can do with Autumn
|
||||
</>
|
||||
) : (
|
||||
"4 steps to get your app's billing done in less than 30 minutes"
|
||||
"Setup billing and gating in your app in under 30 minutes"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
<div
|
||||
className="flex-1 flex flex-col p-4 overflow-auto gap-4 justify-center bg-card [--dot-color:rgba(0,0,0,0.15)] dark:[--dot-color:rgba(255,255,255,0.12)]"
|
||||
className="flex-1 flex flex-col p-4 overflow-auto bg-card [--dot-color:rgba(0,0,0,0.15)] dark:[--dot-color:rgba(255,255,255,0.12)]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, var(--dot-color) 1px, transparent 1px)",
|
||||
@@ -73,17 +91,13 @@ export function PricingPreview({
|
||||
}}
|
||||
>
|
||||
{hasProducts && (
|
||||
<>
|
||||
<div className="flex gap-3 flex-wrap justify-center">
|
||||
{previewProducts.map((product) => (
|
||||
<PreviewPlanCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
previewApiKey={previewOrg?.apiKey}
|
||||
isSyncing={isSyncing}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 my-auto">
|
||||
<GroupedPlanCards
|
||||
products={previewProducts}
|
||||
previewApiKey={previewOrg?.apiKey}
|
||||
isSyncing={isSyncing}
|
||||
changedProductIds={changedProductIds}
|
||||
/>
|
||||
|
||||
{/* 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)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<PromptInputButton
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
disabled={disabled}
|
||||
title="Add image"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
</PromptInputButton>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header showing attached files in the prompt input
|
||||
*/
|
||||
export function AttachmentsHeader() {
|
||||
const attachments = usePromptInputAttachments();
|
||||
|
||||
if (!attachments.files.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PromptInputHeader>
|
||||
<PromptInputAttachments className="p-0 pt-2">
|
||||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||||
</PromptInputAttachments>
|
||||
</PromptInputHeader>
|
||||
);
|
||||
}
|
||||
169
vite/src/views/onboarding4/components/PricingChatPanel.tsx
Normal file
169
vite/src/views/onboarding4/components/PricingChatPanel.tsx
Normal file
@@ -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 (
|
||||
<div className={cn("flex flex-col min-h-0", className)}>
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent className="px-6">
|
||||
{messages.map((message) => (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<MessageContent>
|
||||
{message.parts.map((part, partIndex) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<MessageResponse key={partIndex}>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
);
|
||||
|
||||
case "file": {
|
||||
const isImage = part.mediaType?.startsWith("image/");
|
||||
if (!isImage || !part.url) return null;
|
||||
return (
|
||||
<div
|
||||
key={partIndex}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-1.5 py-1 w-fit"
|
||||
>
|
||||
<img
|
||||
src={part.url}
|
||||
alt={part.filename || "Attached image"}
|
||||
className="w-90 rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case "tool-build_pricing": {
|
||||
const toolPart = part as BuildPricingToolPart;
|
||||
return (
|
||||
<div
|
||||
key={toolPart.toolCallId}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
{toolPart.state === "input-streaming" ||
|
||||
toolPart.state === "input-available" ? (
|
||||
<Shimmer className="text-sm">
|
||||
Building pricing configuration
|
||||
</Shimmer>
|
||||
) : toolPart.state === "output-error" ? (
|
||||
<span className="text-t4">
|
||||
Error generating pricing
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-t4 font-normal">
|
||||
Generated {toolPart.input?.products.length ?? 0}{" "}
|
||||
product(s) and{" "}
|
||||
{toolPart.input?.features.length ?? 0}{" "}
|
||||
feature(s)
|
||||
</span>
|
||||
{onViewJson && toolPart.input && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (toolPart.input) {
|
||||
onViewJson(toolPart.input);
|
||||
}
|
||||
}}
|
||||
className="text-t3 text-xs"
|
||||
>
|
||||
View JSON
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
))}
|
||||
{isLoading &&
|
||||
messages.length > 0 &&
|
||||
messages[messages.length - 1]?.role === "user" && (
|
||||
<div className="flex items-center gap-2 text-t3 text-sm">
|
||||
<Shimmer>Planning next steps</Shimmer>
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
|
||||
<div className={cn("px-6 pb-6 pt-2", inputClassName)}>
|
||||
<PromptInput onSubmit={onSubmit} accept="image/*" multiple>
|
||||
<AttachmentsHeader />
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="justify-between">
|
||||
<ImageUploadButton disabled={isLoading} />
|
||||
<PromptInputSubmit disabled={isLoading} variant="primary" />
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
244
vite/src/views/onboarding4/hooks/usePricingAgentChat.ts
Normal file
244
vite/src/views/onboarding4/hooks/usePricingAgentChat.ts
Normal file
@@ -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<AgentPricingConfig | null>(
|
||||
options?.initialConfig ?? null,
|
||||
);
|
||||
const [jsonSheetConfig, setJsonSheetConfig] =
|
||||
useState<AgentPricingConfig | null>(null);
|
||||
const [previewOrg, setPreviewOrg] = useState<PreviewOrg | null>(null);
|
||||
const [isPreviewSyncing, setIsPreviewSyncing] = useState(false);
|
||||
const previewSetupRef = useRef<Promise<PreviewOrg | null> | 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<PreviewOrg | null> => {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
49
vite/src/views/onboarding4/preview/GroupedPlanCards.tsx
Normal file
49
vite/src/views/onboarding4/preview/GroupedPlanCards.tsx
Normal file
@@ -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<string>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<SectionTag className="self-center">{title}</SectionTag>
|
||||
<div className="flex gap-3 flex-wrap justify-center">
|
||||
{items.map((product) => (
|
||||
<PreviewPlanCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
previewApiKey={previewApiKey}
|
||||
isSyncing={isSyncing}
|
||||
isChanged={changedProductIds.has(product.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderSection("Subscriptions", subscriptions)}
|
||||
{renderSection("Add-on subscriptions", addOnSubscriptions)}
|
||||
{renderSection("One-off purchases", oneTimePlans)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<CheckoutState>("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 ? (
|
||||
<>
|
||||
|
||||
@@ -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 (
|
||||
<Card className="w-[280px] bg-background flex flex-col gap-0 rounded-xl">
|
||||
<Card
|
||||
className={cn(
|
||||
"w-[270px] bg-background flex flex-col gap-0 rounded-xl",
|
||||
isChanged &&
|
||||
"ring-2 ring-yellow-400/70 ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className=" rounded-md">
|
||||
|
||||
@@ -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: <ToggleRightIcon size={size} weight={weight} />,
|
||||
color: "text-red-500",
|
||||
label: "Boolean",
|
||||
};
|
||||
|
||||
case "credit_system":
|
||||
return {
|
||||
icon: <CoinsIcon size={size} weight={weight} />,
|
||||
color: "text-pink-500",
|
||||
label: "Credit System",
|
||||
};
|
||||
|
||||
case "continuous_use":
|
||||
return {
|
||||
icon: <TicketIcon size={size} weight={weight} />,
|
||||
color: "text-blue-500",
|
||||
label: "Non-consumable",
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
icon: <BatteryHighIcon size={size} weight={weight} />,
|
||||
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 (
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center w-full px-2">
|
||||
<div className="flex items-center w-full">
|
||||
<div
|
||||
className={`flex gap-1 min-w-0 overflow-hidden ${useColumnLayout ? "flex-col" : "flex-row items-center"}`}
|
||||
>
|
||||
@@ -35,7 +33,7 @@ export function PreviewFeatureRow({ item }: PreviewFeatureRowProps) {
|
||||
</span>
|
||||
</div>
|
||||
{item.display.secondaryText && (
|
||||
<span className="text-xs text-t3 truncate shrink-0">
|
||||
<span className="text-xs text-t3 truncate min-w-0">
|
||||
{item.display.secondaryText}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<Card className="w-[280px] bg-background flex flex-col gap-0 rounded-xl">
|
||||
<Card
|
||||
className={cn(
|
||||
"w-[270px] bg-interactive-secondary dark:bg-background flex flex-col gap-0 rounded-xl",
|
||||
isChanged &&
|
||||
"ring-2 ring-yellow-400/70 ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
>
|
||||
<CardHeader className="">
|
||||
<PreviewPlanHeader product={product} />
|
||||
</CardHeader>
|
||||
@@ -39,7 +48,7 @@ export function PreviewPlanCard({
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{previewApiKey && product.basePrice.type !== "free" && (
|
||||
{product.basePrice.type !== "free" && (
|
||||
<CardFooter className="pt-3">
|
||||
<PreviewCheckoutButton
|
||||
productId={product.id}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function PreviewPlanHeader({ product }: PreviewPlanHeaderProps) {
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* Name row with badges */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
<span className="text-sm font-medium text-foreground truncate max-w-[160px]">
|
||||
{product.name}
|
||||
</span>
|
||||
<PlanTypeBadges
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import {
|
||||
AppEnv,
|
||||
type AgentFeature,
|
||||
type AgentPricingConfig,
|
||||
type AgentProduct,
|
||||
type AgentProductItem,
|
||||
agentFeatureToFeature,
|
||||
agentItemToProductItem,
|
||||
agentProductToProductV2,
|
||||
type Feature,
|
||||
FeatureType,
|
||||
getProductItemDisplay,
|
||||
Infinite,
|
||||
type ProductItem,
|
||||
type ProductV2,
|
||||
isOneOffProductV2,
|
||||
productV2ToFrontendProduct,
|
||||
} from "@autumn/shared";
|
||||
import {
|
||||
type BasePriceDisplayResult,
|
||||
getBasePriceDisplay,
|
||||
} from "@/utils/product/basePriceDisplayUtils";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentProduct,
|
||||
AgentProductItem,
|
||||
} from "../pricingAgentUtils";
|
||||
|
||||
/**
|
||||
* Preview-friendly product format derived from AgentPricingConfig
|
||||
@@ -27,6 +25,7 @@ export interface PreviewProduct {
|
||||
description?: string;
|
||||
isAddOn?: boolean;
|
||||
isDefault?: boolean;
|
||||
isOneOff: boolean;
|
||||
basePrice: BasePriceDisplayResult;
|
||||
items: PreviewProductItem[];
|
||||
freeTrial?: {
|
||||
@@ -69,6 +68,15 @@ export function transformToPreviewProducts({
|
||||
const frontendProduct = productV2ToFrontendProduct({ product: productV2 });
|
||||
const basePrice = getBasePriceDisplay({ product: frontendProduct });
|
||||
|
||||
// Normalize items for isOneOffProductV2 check (undefined → null for interval)
|
||||
// This is needed because agentItemToProductItem may leave interval as undefined,
|
||||
// but isOneOffProductV2 checks for interval === null
|
||||
const normalizedItems = productV2.items.map((item) => ({
|
||||
...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<string> {
|
||||
const changedIds = new Set<string>();
|
||||
|
||||
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, { all: number }>;
|
||||
}): 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<string> {
|
||||
const changedIds = new Set<string>();
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
5
vite/src/views/onboarding4/utils/convertToAgentConfig.ts
Normal file
5
vite/src/views/onboarding4/utils/convertToAgentConfig.ts
Normal file
@@ -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";
|
||||
@@ -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 (
|
||||
<SheetSection
|
||||
title="Base Price"
|
||||
withSeparator={withSeparator}
|
||||
description={`Flat-rate cost to access this plan.${isCreatingNewPlan ? " You can add per-unit prices later." : ""}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<GroupedTabButton
|
||||
value={basePriceType ?? "recurring"}
|
||||
className="w-full"
|
||||
onValueChange={(value) => {
|
||||
//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: (
|
||||
<ArrowsClockwiseIcon
|
||||
className="size-[14px]"
|
||||
weight="regular"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "one-off",
|
||||
label: "One-off",
|
||||
icon: (
|
||||
<CheckCircleIcon className="size-[14px]" weight="regular" />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "usage",
|
||||
label: "Per unit only",
|
||||
icon: <BarcodeIcon className="size-[14px]" weight="regular" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* <p className="text-t4 text-sm">{priceDescription}</p> */}
|
||||
<SheetSection title="Plan Price" withSeparator={withSeparator}>
|
||||
<div className="space-y-4">
|
||||
<div className="py-2">
|
||||
<RadioGroup
|
||||
value={isPerUnitOnly ? "usage" : "base"}
|
||||
onValueChange={handlePriceTypeChange}
|
||||
className="space-y-1"
|
||||
>
|
||||
<AreaRadioGroupItem
|
||||
value="base"
|
||||
label="Base price"
|
||||
description={`This plan has a fixed price. ${isCreatingNewPlan ? "You can add per-unit prices later." : ""}`}
|
||||
/>
|
||||
<AreaRadioGroupItem
|
||||
value="usage"
|
||||
label="Per unit only"
|
||||
description="Plan price is based entirely on usage or units purchased."
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="h-13">
|
||||
{basePriceType !== "usage" ? (
|
||||
|
||||
{isPerUnitOnly ? (
|
||||
<InfoBox variant="note">
|
||||
This plan has no base price. You can add per unit prices, such as
|
||||
per "seat" or "credit", when adding features.
|
||||
</InfoBox>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<GroupedTabButton
|
||||
value={billingType}
|
||||
className="w-full"
|
||||
onValueChange={handleBillingTypeChange}
|
||||
options={[
|
||||
{
|
||||
value: "recurring",
|
||||
label: "Recurring",
|
||||
icon: (
|
||||
<ArrowsClockwiseIcon
|
||||
className="size-[14px]"
|
||||
weight="regular"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "one-off",
|
||||
label: "One-off",
|
||||
icon: (
|
||||
<CheckCircleIcon
|
||||
className="size-[14px]"
|
||||
weight="regular"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-full">
|
||||
<FormLabel disabled={disabled}>Price</FormLabel>
|
||||
@@ -225,24 +276,19 @@ export const BasePriceSection = ({
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
{basePriceType === "recurring" && (
|
||||
{billingType === "recurring" && (
|
||||
<div className="w-full">
|
||||
<SelectBillingCycle
|
||||
item={basePrice}
|
||||
setItem={setItem}
|
||||
disabled={disabled}
|
||||
filterOneOff={basePriceType === "recurring"}
|
||||
filterOneOff={billingType === "recurring"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<InfoBox variant="note">
|
||||
This plan has no base price. You can add per unit prices, such as
|
||||
per "seat" or "credit", when adding features.
|
||||
</InfoBox>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetSection>
|
||||
);
|
||||
|
||||
@@ -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<void>;
|
||||
}) => {
|
||||
const validated = validateItemsBeforeSave(
|
||||
|
||||
@@ -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<ProductsViewMode>("list");
|
||||
|
||||
return (
|
||||
<div className="h-fit max-h-full px-10">
|
||||
<ProductListTable />
|
||||
{/* Shared header - always visible */}
|
||||
<ProductsPageHeader>
|
||||
<ProductsViewToggle value={viewMode} onValueChange={setViewMode} />
|
||||
<ProductListCreateButton />
|
||||
<ProductListMenuButton />
|
||||
</ProductsPageHeader>
|
||||
|
||||
{/* Conditional content */}
|
||||
{viewMode === "list" ? <ProductListTable /> : <ProductsAIChatView />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create new versions?</DialogTitle>
|
||||
<DialogDescription className="text-sm flex flex-col gap-4">
|
||||
<p>
|
||||
The following plans have active customers and will create{" "}
|
||||
<span className="font-bold">new versions</span>:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
{productIds.map((productId) => (
|
||||
<li key={productId}>
|
||||
<code className="font-bold">{productId}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>
|
||||
Type <code className="font-bold">{CONFIRM_TEXT}</code> to continue.
|
||||
</p>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
type="text"
|
||||
placeholder={CONFIRM_TEXT}
|
||||
className="w-full"
|
||||
/>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="secondary" onClick={() => handleOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleConfirm}
|
||||
isLoading={isLoading}
|
||||
disabled={!isValid}
|
||||
>
|
||||
Create new versions
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="w-full h-[calc(100vh-200px)] flex flex-col">
|
||||
<div className="flex-1 flex min-h-0 relative">
|
||||
{/* Left: Chat - width animates from 0 */}
|
||||
<div
|
||||
className="flex flex-col overflow-hidden transition-all duration-300 ease-out"
|
||||
style={{ width: chatStarted ? "33.333%" : "0%" }}
|
||||
>
|
||||
{chatStarted && (
|
||||
<PricingChatPanel
|
||||
messages={messages}
|
||||
input={input}
|
||||
onInputChange={setInput}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={isLoading}
|
||||
onViewJson={setJsonSheetConfig}
|
||||
placeholder="Describe changes to your pricing..."
|
||||
className="flex-1"
|
||||
inputClassName="px-0 pb-0 mr-4"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Pricing Preview - shrinks as chat expands */}
|
||||
<div className="flex-1 flex flex-col transition-all duration-300 ease-out">
|
||||
<PricingPreview
|
||||
config={displayConfig}
|
||||
initialConfig={initialConfig}
|
||||
previewOrg={previewOrg}
|
||||
isSyncing={isPreviewSyncing}
|
||||
headerActions={
|
||||
displayConfig &&
|
||||
displayConfig.products.length > 0 &&
|
||||
chatStarted && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleStartNewChat}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => handleSave({ config: displayConfig })}
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Floating input when chat hasn't started */}
|
||||
{!chatStarted && (
|
||||
<div className="absolute inset-0 flex items-end justify-center pb-16 pointer-events-none">
|
||||
<div className="pointer-events-auto w-full max-w-md">
|
||||
<CompactPromptInput
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={() => handleSubmit({ text: input, files: [] })}
|
||||
placeholder="Describe changes to your pricing..."
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PricingConfigSheet
|
||||
open={jsonSheetConfig !== null}
|
||||
onOpenChange={(open) => !open && setJsonSheetConfig(null)}
|
||||
config={jsonSheetConfig}
|
||||
/>
|
||||
<ConfirmBatchVersionDialog
|
||||
open={confirmOpen}
|
||||
onOpenChange={setConfirmOpen}
|
||||
productIds={versionedProductIds}
|
||||
onConfirm={handleConfirmSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-2 h-10 pb-4">
|
||||
<div className="flex w-full justify-between items-center">
|
||||
<div className="text-t2 text-md py-0 px-2 rounded-lg flex gap-2 items-center">
|
||||
<CubeIcon size={16} weight="fill" className="text-subtle" />
|
||||
Plans
|
||||
</div>
|
||||
<div className="flex items-center gap-2">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: <ListBulletsIcon className="size-3.5" />,
|
||||
title: "List view",
|
||||
},
|
||||
{
|
||||
value: "ai",
|
||||
icon: <SquareHalfIcon className="size-3.5" />,
|
||||
title: "AI assistant",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center", className)}>
|
||||
{options.map((option, index) => {
|
||||
const isActive = value === option.value;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === options.length - 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
title={option.title}
|
||||
onClick={() => onValueChange(option.value)}
|
||||
className={cn(
|
||||
"flex items-center justify-center p-1.5 h-7 w-7 border transition-none outline-none text-t3",
|
||||
"hover:text-primary focus-visible:text-primary",
|
||||
isActive && "text-primary bg-white dark:bg-zinc-900",
|
||||
!isActive && "bg-zinc-100 dark:bg-zinc-800",
|
||||
isFirst && "rounded-l-md border-l",
|
||||
!isFirst && "border-l-0",
|
||||
isLast && "rounded-r-md",
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
<div className="flex flex-col gap-8">
|
||||
{showTableStructure ? (
|
||||
<>
|
||||
{/* Recurring Plans Section */}
|
||||
<div>
|
||||
<Table.Provider
|
||||
config={{
|
||||
table: recurringBaseTable,
|
||||
numberOfColumns: columns.length,
|
||||
enableSorting,
|
||||
isLoading: isCountsLoading,
|
||||
getRowHref,
|
||||
emptyStateText: queryStates.showArchivedProducts
|
||||
? "You haven't archived any plans yet"
|
||||
: "You haven't created any main plans yet",
|
||||
rowClassName: "h-10",
|
||||
}}
|
||||
>
|
||||
<Table.Toolbar>
|
||||
<div className="flex w-full justify-between items-center">
|
||||
<Table.Heading>
|
||||
<CubeIcon size={16} weight="fill" className="text-subtle" />
|
||||
Recurring Plans
|
||||
</Table.Heading>
|
||||
<Table.Actions>
|
||||
<div className="flex w-full justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProductListCreateButton />
|
||||
<ProductListMenuButton />
|
||||
</div>
|
||||
</div>
|
||||
</Table.Actions>
|
||||
</div>
|
||||
</Table.Toolbar>
|
||||
<Table.Container>
|
||||
{hasRecurringAddOns && <SectionTag>Main Plans</SectionTag>}
|
||||
<Table.Content>
|
||||
<Table.Header />
|
||||
<Table.Body />
|
||||
</Table.Content>
|
||||
</Table.Container>
|
||||
</Table.Provider>
|
||||
{/* Plans Section */}
|
||||
<div>
|
||||
<Table.Provider
|
||||
config={{
|
||||
table: recurringBaseTable,
|
||||
numberOfColumns: columns.length,
|
||||
enableSorting,
|
||||
isLoading: isCountsLoading,
|
||||
getRowHref,
|
||||
emptyStateText: queryStates.showArchivedProducts
|
||||
? "You haven't archived any plans yet"
|
||||
: "Recurring plans that bill customers on a regular schedule",
|
||||
rowClassName: "h-10",
|
||||
}}
|
||||
>
|
||||
<Table.Container>
|
||||
<SectionTag>Subscriptions</SectionTag>
|
||||
<Table.Content>
|
||||
<Table.Header />
|
||||
<Table.Body />
|
||||
</Table.Content>
|
||||
</Table.Container>
|
||||
</Table.Provider>
|
||||
|
||||
{/* Recurring Add-ons (only shown if add-ons exist) */}
|
||||
{/* Add-on Plans (only shown when add-ons exist) */}
|
||||
{hasRecurringAddOns && (
|
||||
<Table.Provider
|
||||
config={{
|
||||
@@ -207,42 +189,35 @@ export function ProductListTable() {
|
||||
}}
|
||||
>
|
||||
<Table.Container>
|
||||
<SectionTag className="mt-4">Add-ons</SectionTag>
|
||||
<SectionTag className="mt-4">Add-on subscriptions</SectionTag>
|
||||
<Table.Content>
|
||||
<Table.Body />
|
||||
</Table.Content>
|
||||
</Table.Container>
|
||||
</Table.Provider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* One-Time Plans Section (only shown if one-time plans exist) */}
|
||||
{hasOneTimePlans && (
|
||||
<div>
|
||||
<Table.Provider
|
||||
config={{
|
||||
table: oneTimeTable,
|
||||
numberOfColumns: columns.length,
|
||||
enableSorting,
|
||||
isLoading: isCountsLoading,
|
||||
getRowHref,
|
||||
rowClassName: "h-10",
|
||||
}}
|
||||
>
|
||||
<Table.Toolbar>
|
||||
<Table.Heading>
|
||||
<CubeIcon size={16} weight="fill" className="text-subtle" />
|
||||
One-off Plans
|
||||
</Table.Heading>
|
||||
</Table.Toolbar>
|
||||
<Table.Container>
|
||||
<Table.Content>
|
||||
<Table.Body />
|
||||
</Table.Content>
|
||||
</Table.Container>
|
||||
</Table.Provider>
|
||||
</div>
|
||||
)}
|
||||
{/* One-time Plans (always shown) */}
|
||||
<Table.Provider
|
||||
config={{
|
||||
table: oneTimeTable,
|
||||
numberOfColumns: columns.length,
|
||||
enableSorting,
|
||||
isLoading: isCountsLoading,
|
||||
getRowHref,
|
||||
emptyStateText:
|
||||
"One-time prices for top-ups or lifetime purchases",
|
||||
rowClassName: "h-10",
|
||||
}}
|
||||
>
|
||||
<Table.Container>
|
||||
<SectionTag className="mt-4">One-off purchases</SectionTag>
|
||||
<Table.Content>
|
||||
<Table.Body />
|
||||
</Table.Content>
|
||||
</Table.Container>
|
||||
</Table.Provider>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState type="plans" actionButton={<ProductListCreateButton />} />
|
||||
|
||||
147
vite/src/views/products/products/hooks/usePricingConfigSave.ts
Normal file
147
vite/src/views/products/products/hooks/usePricingConfigSave.ts
Normal file
@@ -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<string[]>([]);
|
||||
const [pendingConfig, setPendingConfig] = useState<AgentPricingConfig | null>(
|
||||
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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user