diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 546e0f5fb6a..bc3a0da86a7 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -410,6 +410,8 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS integrationGoogleCreate(input: IntegrationGoogleCreateInput!) : IntegrationGoogle! @join__field(graph: API_JOURNEYS_MODERN) integrationGoogleUpdate(id: ID!, input: IntegrationGoogleUpdateInput!) : IntegrationGoogle! @join__field(graph: API_JOURNEYS_MODERN) integrationDelete(id: ID!) : Integration! @join__field(graph: API_JOURNEYS_MODERN) + journeyAiChatUndo(turnId: String!) : Boolean! @join__field(graph: API_JOURNEYS_MODERN) + journeyAiChatExecutePlan(turnId: String!) : Boolean! @join__field(graph: API_JOURNEYS_MODERN) journeyAiTranslateCreate(input: JourneyAiTranslateInput!) : Journey! @join__field(graph: API_JOURNEYS_MODERN) createJourneyEventsExportLog(input: JourneyEventsExportLogInput!) : JourneyEventsExportLog! @join__field(graph: API_JOURNEYS_MODERN) journeyLanguageAiDetect(input: MutationJourneyLanguageAiDetectInput!) : Boolean! @join__field(graph: API_JOURNEYS_MODERN) @@ -2389,6 +2391,23 @@ type GoogleSheetsSync @join__type(graph: API_JOURNEYS_MODERN) { journey: Journey! } +type JourneyAiChatMessage @join__type(graph: API_JOURNEYS_MODERN) { + type: String + text: String + operations: String + operationId: String + status: String + turnId: String + journeyUpdated: Boolean + requiresConfirmation: Boolean + name: String + args: String + summary: String + cardId: String + error: String + validation: String +} + type JourneyAiTranslateProgress @join__type(graph: API_JOURNEYS_MODERN) { """ Translation progress as a percentage (0-100) @@ -2525,6 +2544,7 @@ type PlausibleStatsResponse @join__type(graph: API_JOURNEYS_MODERN) { } type Subscription @join__type(graph: API_JOURNEYS_MODERN) { + journeyAiChatCreateSubscription(input: JourneyAiChatInput!) : JourneyAiChatMessage! journeyAiTranslateCreateSubscription(input: JourneyAiTranslateInput!) : JourneyAiTranslateProgress! } @@ -3727,6 +3747,11 @@ enum GoogleSheetExportMode @join__type(graph: API_JOURNEYS_MODERN) { existing @join__enumValue(graph: API_JOURNEYS_MODERN) } +enum JourneyAiChatPreferredTier @join__type(graph: API_JOURNEYS_MODERN) { + free @join__enumValue(graph: API_JOURNEYS_MODERN) + premium @join__enumValue(graph: API_JOURNEYS_MODERN) +} + enum PlausibleEvent @join__type(graph: API_JOURNEYS_MODERN) { footerThumbsUpButtonClick @join__enumValue(graph: API_JOURNEYS_MODERN) footerThumbsDownButtonClick @join__enumValue(graph: API_JOURNEYS_MODERN) @@ -4804,6 +4829,21 @@ input IntegrationGoogleUpdateInput @join__type(graph: API_JOURNEYS_MODERN) { redirectUri: String! } +input JourneyAiChatHistoryMessage @join__type(graph: API_JOURNEYS_MODERN) { + role: String! + content: String! +} + +input JourneyAiChatInput @join__type(graph: API_JOURNEYS_MODERN) { + journeyId: ID! + message: String! + history: [JourneyAiChatHistoryMessage!]! + turnId: String + contextCardId: String + preferredTier: JourneyAiChatPreferredTier + languageName: String +} + input JourneyAiTranslateInput @join__type(graph: API_JOURNEYS_MODERN) { journeyId: ID! name: String! diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index 4bd54dbede3..26d77f668bf 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -823,6 +823,43 @@ type Journey journeyCollections: [JourneyCollection!]! } +input JourneyAiChatHistoryMessage { + role: String! + content: String! +} + +input JourneyAiChatInput { + journeyId: ID! + message: String! + history: [JourneyAiChatHistoryMessage!]! + turnId: String + contextCardId: String + preferredTier: JourneyAiChatPreferredTier + languageName: String +} + +type JourneyAiChatMessage { + type: String + text: String + operations: String + operationId: String + status: String + turnId: String + journeyUpdated: Boolean + requiresConfirmation: Boolean + name: String + args: String + summary: String + cardId: String + error: String + validation: String +} + +enum JourneyAiChatPreferredTier { + free + premium +} + input JourneyAiTranslateInput { journeyId: ID! name: String! @@ -1401,6 +1438,8 @@ type Mutation { integrationGoogleCreate(input: IntegrationGoogleCreateInput!): IntegrationGoogle! integrationGoogleUpdate(id: ID!, input: IntegrationGoogleUpdateInput!): IntegrationGoogle! integrationDelete(id: ID!): Integration! + journeyAiChatUndo(turnId: String!): Boolean! + journeyAiChatExecutePlan(turnId: String!): Boolean! journeyAiTranslateCreate(input: JourneyAiTranslateInput!): Journey! createJourneyEventsExportLog(input: JourneyEventsExportLogInput!): JourneyEventsExportLog! journeyLanguageAiDetect(input: MutationJourneyLanguageAiDetectInput!): Boolean! @@ -2081,6 +2120,7 @@ input StepViewEventCreateInput { } type Subscription { + journeyAiChatCreateSubscription(input: JourneyAiChatInput!): JourneyAiChatMessage! journeyAiTranslateCreateSubscription(input: JourneyAiTranslateInput!): JourneyAiTranslateProgress! } diff --git a/apis/api-journeys-modern/src/env.ts b/apis/api-journeys-modern/src/env.ts index 8bed97d2c7f..76c027638e9 100644 --- a/apis/api-journeys-modern/src/env.ts +++ b/apis/api-journeys-modern/src/env.ts @@ -9,6 +9,7 @@ export const env = createEnv({ throw new Error('Invalid environment variables') }, server: { + ANTHROPIC_API_KEY: z.string().trim().min(1).optional(), CLOUDFLARE_UPLOAD_KEY: z.string().trim().min(1), FACEBOOK_APP_ID: z.string().trim().min(1), FACEBOOK_APP_SECRET: z.string().trim().min(1), @@ -29,6 +30,7 @@ export const env = createEnv({ }), REDIS_PORT: z.coerce.number().int().positive().default(6379), REDIS_URL: z.string().trim().min(1).default('redis'), - SERVICE_VERSION: z.string().trim().default('') + SERVICE_VERSION: z.string().trim().default(''), + UNSPLASH_ACCESS_KEY: z.string().trim().min(1).optional() } }) diff --git a/apis/api-journeys-modern/src/schema/journey/journey.ts b/apis/api-journeys-modern/src/schema/journey/journey.ts index a38b837e12f..88f6861800f 100644 --- a/apis/api-journeys-modern/src/schema/journey/journey.ts +++ b/apis/api-journeys-modern/src/schema/journey/journey.ts @@ -55,7 +55,8 @@ export const JourneyRef = builder.prismaObject('Journey', { resolve: (journey) => ({ id: journey.languageId ?? '529' }) }), blocks: t.relation('blocks', { - nullable: true + nullable: true, + query: { where: { deletedAt: null } } }), chatButtons: t.relation('chatButtons', { nullable: false diff --git a/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.spec.ts b/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.spec.ts index a96e38de381..624038010b0 100644 --- a/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.spec.ts +++ b/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.spec.ts @@ -12,15 +12,21 @@ describe('simplifyJourney', () => { title: 'Test Journey', description: 'A test journey', blocks: [] - } as any // as any to allow minimal mock + } as any - it('transforms a journey with one step/card and minimal blocks', () => { + it('should transform a journey with heading and button (content array format)', () => { const journey: TestJourney = { ...baseJourney, blocks: [ { id: 'step-1', typename: 'StepBlock' }, { id: 'card-1', typename: 'CardBlock', parentBlockId: 'step-1' }, - // Add a button to satisfy navigation requirement + { + id: 'heading-1', + typename: 'TypographyBlock', + parentBlockId: 'card-1', + variant: 'h3', + content: 'Welcome' + }, { id: 'button-1', typename: 'ButtonBlock', @@ -33,364 +39,767 @@ describe('simplifyJourney', () => { const result = simplifyJourney(journey) expect(result.title).toBe('Test Journey') expect(result.description).toBe('A test journey') - expect(result.cards.length).toBe(1) - expect(journeySimpleSchema.safeParse(result).success).toBe(true) + expect(result.cards).toHaveLength(1) + const card = result.cards[0] + expect(card.content).toHaveLength(2) + expect(card.content[0]).toEqual({ + type: 'heading', + text: 'Welcome', + variant: 'h3' + }) + expect(card.content[1]).toEqual({ + type: 'button', + text: 'Next', + action: { kind: 'url', url: 'https://example.com' } + }) }) - it('transforms a journey with heading, text, button, poll, image, backgroundImage, and navigation', () => { + it('should transform a journey with all block types (heading, text, button, image, video, poll)', () => { const journey: TestJourney = { ...baseJourney, blocks: [ { id: 'step-1', typename: 'StepBlock', - nextBlockId: 'step-2', - coverBlockId: 'bg-1' + nextBlockId: 'step-2' }, { id: 'card-1', typename: 'CardBlock', - parentBlockId: 'step-1', - coverBlockId: 'bg-1' + parentBlockId: 'step-1' }, { id: 'heading-1', typename: 'TypographyBlock', parentBlockId: 'card-1', variant: 'h3', - content: 'Heading' + content: 'Welcome', + parentOrder: 0 }, { id: 'text-1', typename: 'TypographyBlock', parentBlockId: 'card-1', variant: 'body1', - content: 'Text' + content: 'Some body text', + parentOrder: 1 }, { id: 'button-1', typename: 'ButtonBlock', parentBlockId: 'card-1', - label: 'Next', - action: { blockId: 'step-2' } + label: 'Go', + action: { blockId: 'step-2' }, + parentOrder: 2 + }, + { + id: 'image-1', + typename: 'ImageBlock', + parentBlockId: 'card-1', + src: 'img.jpg', + alt: 'photo', + width: 100, + height: 50, + blurhash: 'LGF5#?xD', + parentOrder: 3 + }, + { + id: 'video-1', + typename: 'VideoBlock', + parentBlockId: 'card-1', + source: 'youTube', + videoId: 'abc123', + startAt: 10, + endAt: 60, + parentOrder: 4 }, { id: 'poll-1', typename: 'RadioQuestionBlock', - parentBlockId: 'card-1' + parentBlockId: 'card-1', + parentOrder: 5 }, { id: 'option-1', typename: 'RadioOptionBlock', parentBlockId: 'poll-1', - label: 'Option 1', - action: { blockId: 'step-2' } + label: 'Option A', + action: { blockId: 'step-2' }, + parentOrder: 0 }, { id: 'option-2', typename: 'RadioOptionBlock', parentBlockId: 'poll-1', - label: 'Option 2', - action: { url: 'https://example.com' } + label: 'Option B', + action: { url: 'https://example.com' }, + parentOrder: 1 }, + { id: 'step-2', typename: 'StepBlock' }, { - id: 'image-1', - typename: 'ImageBlock', - parentBlockId: 'card-1', - src: 'img.jpg', - alt: 'alt', - width: 100, - height: 100, - blurhash: '' + id: 'card-2', + typename: 'CardBlock', + parentBlockId: 'step-2' }, { - id: 'bg-1', - typename: 'ImageBlock', + id: 'heading-2', + typename: 'TypographyBlock', + parentBlockId: 'card-2', + variant: 'h3', + content: 'Done' + } + ] as any + } + const result = simplifyJourney(journey) + expect(result.cards).toHaveLength(2) + const card = result.cards[0] + expect(card.content).toHaveLength(6) + expect(card.content[0]).toEqual({ + type: 'heading', + text: 'Welcome', + variant: 'h3' + }) + expect(card.content[1]).toEqual({ + type: 'text', + text: 'Some body text', + variant: 'body1' + }) + expect(card.content[2]).toEqual({ + type: 'button', + text: 'Go', + action: { kind: 'navigate', cardId: 'card-done' } + }) + expect(card.content[3]).toEqual({ + type: 'image', + src: 'img.jpg', + alt: 'photo', + width: 100, + height: 50, + blurhash: 'LGF5#?xD' + }) + expect(card.content[4]).toEqual({ + type: 'video', + url: 'https://youtube.com/watch?v=abc123', + startAt: 10, + endAt: 60 + }) + expect(card.content[5]).toEqual({ + type: 'poll', + gridView: false, + options: [ + { + text: 'Option A', + action: { kind: 'navigate', cardId: 'card-done' } + }, + { + text: 'Option B', + action: { kind: 'url', url: 'https://example.com' } + } + ] + }) + expect(card.defaultNextCard).toBe('card-done') + }) + + it('should generate content-derived card IDs from headings', () => { + const journey: TestJourney = { + ...baseJourney, + blocks: [ + { id: 'step-1', typename: 'StepBlock' }, + { id: 'card-1', typename: 'CardBlock', parentBlockId: 'step-1' }, + { + id: 'heading-1', + typename: 'TypographyBlock', parentBlockId: 'card-1', - src: 'bg.jpg', - alt: 'bg', - width: 200, - height: 200, - blurhash: '' + variant: 'h3', + content: 'Welcome Home' + } + ] as any + } + const result = simplifyJourney(journey) + expect(result.cards[0].id).toBe('card-welcome-home') + }) + + it('should fallback to text content for card ID when no heading', () => { + const journey: TestJourney = { + ...baseJourney, + blocks: [ + { id: 'step-1', typename: 'StepBlock' }, + { id: 'card-1', typename: 'CardBlock', parentBlockId: 'step-1' }, + { + id: 'text-1', + typename: 'TypographyBlock', + parentBlockId: 'card-1', + variant: 'body1', + content: 'Some descriptive paragraph text here and more' + } + ] as any + } + const result = simplifyJourney(journey) + // Slug is first 4 words + expect(result.cards[0].id).toBe('card-some-descriptive-paragraph-text') + }) + + it('should handle duplicate headings with suffix', () => { + const journey: TestJourney = { + ...baseJourney, + blocks: [ + { id: 'step-1', typename: 'StepBlock' }, + { id: 'card-1', typename: 'CardBlock', parentBlockId: 'step-1' }, + { + id: 'heading-1', + typename: 'TypographyBlock', + parentBlockId: 'card-1', + variant: 'h3', + content: 'Welcome' }, { id: 'step-2', typename: 'StepBlock' }, { id: 'card-2', typename: 'CardBlock', parentBlockId: 'step-2' }, - // Add a button to card-2 to satisfy navigation requirement { - id: 'button-2', - typename: 'ButtonBlock', + id: 'heading-2', + typename: 'TypographyBlock', parentBlockId: 'card-2', - label: 'Next', - action: { url: 'https://example.com' } + variant: 'h3', + content: 'Welcome' + }, + { id: 'step-3', typename: 'StepBlock' }, + { id: 'card-3', typename: 'CardBlock', parentBlockId: 'step-3' }, + { + id: 'heading-3', + typename: 'TypographyBlock', + parentBlockId: 'card-3', + variant: 'h3', + content: 'Welcome' } ] as any } const result = simplifyJourney(journey) - expect(result.cards.length).toBe(2) - const card = result.cards[0] - expect(card.heading).toBe('Heading') - expect(card.text).toBe('Text') - expect(card.button?.text).toBe('Next') - expect(card.button?.nextCard).toBe('card-2') - expect(card.poll?.length).toBe(2) - expect(card.poll?.[0].text).toBe('Option 1') - expect(card.poll?.[0].nextCard).toBe('card-2') - expect(card.poll?.[1].url).toBe('https://example.com') - expect(card.image?.src).toBe('img.jpg') - expect(card.backgroundImage?.src).toBe('bg.jpg') - expect(card.defaultNextCard).toBe('card-2') - expect(journeySimpleSchema.safeParse(result).success).toBe(true) + expect(result.cards[0].id).toBe('card-welcome') + expect(result.cards[1].id).toBe('card-welcome-2') + expect(result.cards[2].id).toBe('card-welcome-3') }) - it('transforms a journey with video block (YouTube)', () => { + it('should map all 5 action kinds (navigate, url, email, chat, phone)', () => { const journey: TestJourney = { ...baseJourney, blocks: [ + { id: 'step-1', typename: 'StepBlock' }, + { id: 'card-1', typename: 'CardBlock', parentBlockId: 'step-1' }, { - id: 'step-1', - typename: 'StepBlock', - nextBlockId: 'step-2' + id: 'heading-1', + typename: 'TypographyBlock', + parentBlockId: 'card-1', + variant: 'h3', + content: 'Actions' }, { - id: 'card-1', - typename: 'CardBlock', - parentBlockId: 'step-1' + id: 'btn-nav', + typename: 'ButtonBlock', + parentBlockId: 'card-1', + label: 'Navigate', + action: { blockId: 'step-2' }, + parentOrder: 1 }, { - id: 'video-1', - typename: 'VideoBlock', + id: 'btn-url', + typename: 'ButtonBlock', parentBlockId: 'card-1', - source: 'youTube', - videoId: 'dQw4w9WgXcQ', - startAt: 30, - endAt: 120 + label: 'URL', + action: { url: 'https://example.com' }, + parentOrder: 2 + }, + { + id: 'btn-email', + typename: 'ButtonBlock', + parentBlockId: 'card-1', + label: 'Email', + action: { email: 'test@example.com' }, + parentOrder: 3 + }, + { + id: 'btn-chat', + typename: 'ButtonBlock', + parentBlockId: 'card-1', + label: 'Chat', + action: { chatUrl: 'https://wa.me/123' }, + parentOrder: 4 + }, + { + id: 'btn-phone', + typename: 'ButtonBlock', + parentBlockId: 'card-1', + label: 'Phone', + action: { + phone: '+1234567890', + countryCode: 'US', + contactAction: 'call' + }, + parentOrder: 5 }, { id: 'step-2', typename: 'StepBlock' }, { id: 'card-2', typename: 'CardBlock', parentBlockId: 'step-2' }, { - id: 'button-2', - typename: 'ButtonBlock', + id: 'heading-2', + typename: 'TypographyBlock', parentBlockId: 'card-2', - label: 'End', - action: { url: 'https://example.com' } + variant: 'h3', + content: 'Target' } ] as any } const result = simplifyJourney(journey) - expect(result.cards.length).toBe(2) - - const videoCard = result.cards[0] - expect(videoCard.id).toBe('card-1') - expect(videoCard.video).toBeDefined() - expect(videoCard.video?.url).toBe('https://youtube.com/watch?v=dQw4w9WgXcQ') - expect(videoCard.video?.startAt).toBe(30) - expect(videoCard.video?.endAt).toBe(120) - expect(videoCard.defaultNextCard).toBe('card-2') - - // Video cards should not have other content - expect(videoCard.heading).toBeUndefined() - expect(videoCard.text).toBeUndefined() - expect(videoCard.button).toBeUndefined() - expect(videoCard.poll).toBeUndefined() - expect(videoCard.image).toBeUndefined() - expect(videoCard.backgroundImage).toBeUndefined() - - expect(journeySimpleSchema.safeParse(result).success).toBe(true) + const buttons = result.cards[0].content.filter((b) => b.type === 'button') + expect(buttons).toHaveLength(5) + expect(buttons[0]).toEqual({ + type: 'button', + text: 'Navigate', + action: { kind: 'navigate', cardId: 'card-target' } + }) + expect(buttons[1]).toEqual({ + type: 'button', + text: 'URL', + action: { kind: 'url', url: 'https://example.com' } + }) + expect(buttons[2]).toEqual({ + type: 'button', + text: 'Email', + action: { kind: 'email', email: 'test@example.com' } + }) + expect(buttons[3]).toEqual({ + type: 'button', + text: 'Chat', + action: { kind: 'chat', chatUrl: 'https://wa.me/123' } + }) + expect(buttons[4]).toEqual({ + type: 'button', + text: 'Phone', + action: { + kind: 'phone', + phone: '+1234567890', + countryCode: 'US', + contactAction: 'call' + } + }) }) - it('transforms a journey with video block (minimal - no timing)', () => { + it('should handle backgroundImage from cover block', () => { const journey: TestJourney = { ...baseJourney, blocks: [ - { - id: 'step-1', - typename: 'StepBlock', - nextBlockId: 'step-2' - }, + { id: 'step-1', typename: 'StepBlock' }, { id: 'card-1', typename: 'CardBlock', - parentBlockId: 'step-1' + parentBlockId: 'step-1', + coverBlockId: 'cover-img' }, { - id: 'video-1', - typename: 'VideoBlock', + id: 'heading-1', + typename: 'TypographyBlock', parentBlockId: 'card-1', - source: 'youTube', - videoId: 'dQw4w9WgXcQ' - // No startAt/endAt + variant: 'h3', + content: 'Background Test' }, - { id: 'step-2', typename: 'StepBlock' }, - { id: 'card-2', typename: 'CardBlock', parentBlockId: 'step-2' }, { - id: 'button-2', - typename: 'ButtonBlock', - parentBlockId: 'card-2', - label: 'End', - action: { url: 'https://example.com' } + id: 'cover-img', + typename: 'ImageBlock', + parentBlockId: 'card-1', + src: 'bg.jpg', + alt: 'background', + width: 1920, + height: 1080, + blurhash: 'LGF5#?xD' } ] as any } const result = simplifyJourney(journey) - - const videoCard = result.cards[0] - expect(videoCard.video).toBeDefined() - expect(videoCard.video?.url).toBe('https://youtube.com/watch?v=dQw4w9WgXcQ') - expect(videoCard.video?.startAt).toBeUndefined() - expect(videoCard.video?.endAt).toBeUndefined() - expect(videoCard.defaultNextCard).toBe('card-2') - - expect(journeySimpleSchema.safeParse(result).success).toBe(true) + const card = result.cards[0] + expect(card.backgroundImage).toEqual({ + src: 'bg.jpg', + alt: 'background', + width: 1920, + height: 1080, + blurhash: 'LGF5#?xD' + }) + // Cover block should not appear in content array + const images = card.content.filter((b) => b.type === 'image') + expect(images).toHaveLength(0) }) - it('transforms a journey with video block (null timing values)', () => { + it('should handle backgroundVideo from YouTube cover block', () => { const journey: TestJourney = { ...baseJourney, blocks: [ - { - id: 'step-1', - typename: 'StepBlock', - nextBlockId: 'step-2' - }, + { id: 'step-1', typename: 'StepBlock' }, { id: 'card-1', typename: 'CardBlock', - parentBlockId: 'step-1' + parentBlockId: 'step-1', + coverBlockId: 'cover-vid' }, + { + id: 'heading-1', + typename: 'TypographyBlock', + parentBlockId: 'card-1', + variant: 'h3', + content: 'Video Background' + }, + { + id: 'cover-vid', + typename: 'VideoBlock', + parentBlockId: 'card-1', + source: 'youTube', + videoId: 'xyz789', + startAt: 5, + endAt: 30 + } + ] as any + } + const result = simplifyJourney(journey) + const card = result.cards[0] + expect(card.backgroundVideo).toEqual({ + url: 'https://youtube.com/watch?v=xyz789', + startAt: 5, + endAt: 30 + }) + // Cover block should not appear in content array + const videos = card.content.filter((b) => b.type === 'video') + expect(videos).toHaveLength(0) + }) + + it('should handle video content block', () => { + const journey: TestJourney = { + ...baseJourney, + blocks: [ + { id: 'step-1', typename: 'StepBlock' }, + { id: 'card-1', typename: 'CardBlock', parentBlockId: 'step-1' }, { id: 'video-1', typename: 'VideoBlock', parentBlockId: 'card-1', source: 'youTube', videoId: 'dQw4w9WgXcQ', - startAt: null, - endAt: null - }, - { id: 'step-2', typename: 'StepBlock' }, - { id: 'card-2', typename: 'CardBlock', parentBlockId: 'step-2' }, - { - id: 'button-2', - typename: 'ButtonBlock', - parentBlockId: 'card-2', - label: 'End', - action: { url: 'https://example.com' } + startAt: 30, + endAt: 120 } ] as any } const result = simplifyJourney(journey) - - const videoCard = result.cards[0] - expect(videoCard.video?.startAt).toBeUndefined() - expect(videoCard.video?.endAt).toBeUndefined() - - expect(journeySimpleSchema.safeParse(result).success).toBe(true) + const card = result.cards[0] + expect(card.content).toHaveLength(1) + expect(card.content[0]).toEqual({ + type: 'video', + url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', + startAt: 30, + endAt: 120 + }) }) - it('ignores non-YouTube video blocks', () => { + it('should sort child blocks by parentOrder', () => { const journey: TestJourney = { ...baseJourney, blocks: [ + { id: 'step-1', typename: 'StepBlock' }, + { id: 'card-1', typename: 'CardBlock', parentBlockId: 'step-1' }, { - id: 'step-1', - typename: 'StepBlock' - }, - { - id: 'card-1', - typename: 'CardBlock', - parentBlockId: 'step-1' + id: 'text-1', + typename: 'TypographyBlock', + parentBlockId: 'card-1', + variant: 'body1', + content: 'Second', + parentOrder: 1 }, { - id: 'video-1', - typename: 'VideoBlock', + id: 'heading-1', + typename: 'TypographyBlock', parentBlockId: 'card-1', - source: 'internal', // Not YouTube - videoId: 'some-id' + variant: 'h3', + content: 'First', + parentOrder: 0 }, { id: 'button-1', typename: 'ButtonBlock', parentBlockId: 'card-1', - label: 'Next', - action: { url: 'https://example.com' } + label: 'Third', + action: { url: 'https://example.com' }, + parentOrder: 2 } ] as any } const result = simplifyJourney(journey) - const card = result.cards[0] - expect(card.video).toBeUndefined() - expect(card.button?.text).toBe('Next') // Should process as regular card - - expect(journeySimpleSchema.safeParse(result).success).toBe(true) + expect(card.content).toHaveLength(3) + expect(card.content[0]).toEqual({ + type: 'heading', + text: 'First', + variant: 'h3' + }) + expect(card.content[1]).toEqual({ + type: 'text', + text: 'Second', + variant: 'body1' + }) + expect(card.content[2]).toEqual({ + type: 'button', + text: 'Third', + action: { kind: 'url', url: 'https://example.com' } + }) }) - it('transforms video card without step navigation (no defaultNextCard)', () => { + it('should handle multiselect block', () => { const journey: TestJourney = { ...baseJourney, blocks: [ + { id: 'step-1', typename: 'StepBlock' }, + { id: 'card-1', typename: 'CardBlock', parentBlockId: 'step-1' }, { - id: 'step-1', - typename: 'StepBlock' - // No nextBlockId + id: 'heading-1', + typename: 'TypographyBlock', + parentBlockId: 'card-1', + variant: 'h3', + content: 'Preferences', + parentOrder: 0 }, { - id: 'card-1', - typename: 'CardBlock', - parentBlockId: 'step-1' + id: 'multi-1', + typename: 'MultiselectBlock', + parentBlockId: 'card-1', + min: 1, + max: 3, + parentOrder: 1 }, { - id: 'video-1', - typename: 'VideoBlock', - parentBlockId: 'card-1', - source: 'youTube', - videoId: 'dQw4w9WgXcQ' + id: 'mopt-1', + typename: 'MultiselectOptionBlock', + parentBlockId: 'multi-1', + label: 'Alpha', + parentOrder: 0 + }, + { + id: 'mopt-2', + typename: 'MultiselectOptionBlock', + parentBlockId: 'multi-1', + label: 'Beta', + parentOrder: 1 + }, + { + id: 'mopt-3', + typename: 'MultiselectOptionBlock', + parentBlockId: 'multi-1', + label: 'Gamma', + parentOrder: 2 } ] as any } const result = simplifyJourney(journey) + const card = result.cards[0] + const multiselect = card.content.find((b) => b.type === 'multiselect') + expect(multiselect).toEqual({ + type: 'multiselect', + min: 1, + max: 3, + options: ['Alpha', 'Beta', 'Gamma'] + }) + }) - const videoCard = result.cards[0] - expect(videoCard.video).toBeDefined() - expect(videoCard.defaultNextCard).toBeUndefined() + it('should handle textInput block', () => { + const journey: TestJourney = { + ...baseJourney, + blocks: [ + { id: 'step-1', typename: 'StepBlock' }, + { id: 'card-1', typename: 'CardBlock', parentBlockId: 'step-1' }, + { + id: 'heading-1', + typename: 'TypographyBlock', + parentBlockId: 'card-1', + variant: 'h3', + content: 'Contact', + parentOrder: 0 + }, + { + id: 'input-1', + typename: 'TextResponseBlock', + parentBlockId: 'card-1', + label: 'Your email', + type: 'email', + placeholder: 'name@example.com', + hint: 'We will not share your email', + required: true, + parentOrder: 1 + } + ] as any + } + const result = simplifyJourney(journey) + const card = result.cards[0] + const textInput = card.content.find((b) => b.type === 'textInput') + expect(textInput).toEqual({ + type: 'textInput', + label: 'Your email', + inputType: 'email', + placeholder: 'name@example.com', + hint: 'We will not share your email', + required: true + }) + }) - expect(journeySimpleSchema.safeParse(result).success).toBe(true) + it('should handle spacer block', () => { + const journey: TestJourney = { + ...baseJourney, + blocks: [ + { id: 'step-1', typename: 'StepBlock' }, + { id: 'card-1', typename: 'CardBlock', parentBlockId: 'step-1' }, + { + id: 'heading-1', + typename: 'TypographyBlock', + parentBlockId: 'card-1', + variant: 'h3', + content: 'Spaced', + parentOrder: 0 + }, + { + id: 'spacer-1', + typename: 'SpacerBlock', + parentBlockId: 'card-1', + spacing: 24, + parentOrder: 1 + } + ] as any + } + const result = simplifyJourney(journey) + const card = result.cards[0] + const spacer = card.content.find((b) => b.type === 'spacer') + expect(spacer).toEqual({ + type: 'spacer', + spacing: 24 + }) }) - it('handles edge case: missing/extra/invalid data gracefully', () => { + it('should skip non-YouTube video blocks', () => { const journey: TestJourney = { ...baseJourney, blocks: [ { id: 'step-1', typename: 'StepBlock' }, { id: 'card-1', typename: 'CardBlock', parentBlockId: 'step-1' }, - { id: 'unknown-1', typename: 'UnknownBlock', parentBlockId: 'card-1' }, { id: 'heading-1', typename: 'TypographyBlock', parentBlockId: 'card-1', variant: 'h3', - content: 'Heading' + content: 'No Video', + parentOrder: 0 }, - // Add a button to satisfy navigation requirement { - id: 'button-1', - typename: 'ButtonBlock', + id: 'video-1', + typename: 'VideoBlock', parentBlockId: 'card-1', - label: 'Next', - action: { url: 'https://example.com' } + source: 'internal', + videoId: 'some-id', + parentOrder: 1 } ] as any } const result = simplifyJourney(journey) - expect(result.cards.length).toBe(1) - expect(result.cards[0].heading).toBe('Heading') - expect(journeySimpleSchema.safeParse(result).success).toBe(true) + const videos = result.cards[0].content.filter((b) => b.type === 'video') + expect(videos).toHaveLength(0) }) - it('throws error if card block is missing for a step', () => { + it('should throw error if card block is missing', () => { const journey: TestJourney = { ...baseJourney, blocks: [{ id: 'step-1', typename: 'StepBlock' }] as any } expect(() => simplifyJourney(journey)).toThrow('Card block not found') }) + + it('should produce Zod-valid output', () => { + const journey: TestJourney = { + ...baseJourney, + blocks: [ + { id: 'step-1', typename: 'StepBlock', nextBlockId: 'step-2' }, + { + id: 'card-1', + typename: 'CardBlock', + parentBlockId: 'step-1', + backgroundColor: '#1A1A2E', + coverBlockId: 'cover-1' + }, + { + id: 'cover-1', + typename: 'ImageBlock', + parentBlockId: 'card-1', + src: 'bg.jpg', + alt: 'bg' + }, + { + id: 'heading-1', + typename: 'TypographyBlock', + parentBlockId: 'card-1', + variant: 'h1', + content: 'Full Test', + parentOrder: 0 + }, + { + id: 'text-1', + typename: 'TypographyBlock', + parentBlockId: 'card-1', + variant: 'body2', + content: 'Description text', + parentOrder: 1 + }, + { + id: 'button-1', + typename: 'ButtonBlock', + parentBlockId: 'card-1', + label: 'Continue', + action: { blockId: 'step-2' }, + parentOrder: 2 + }, + { + id: 'poll-1', + typename: 'RadioQuestionBlock', + parentBlockId: 'card-1', + gridView: true, + parentOrder: 3 + }, + { + id: 'opt-1', + typename: 'RadioOptionBlock', + parentBlockId: 'poll-1', + label: 'Yes', + action: { blockId: 'step-2' }, + parentOrder: 0 + }, + { + id: 'opt-2', + typename: 'RadioOptionBlock', + parentBlockId: 'poll-1', + label: 'No', + action: { url: 'https://example.com' }, + parentOrder: 1 + }, + { id: 'step-2', typename: 'StepBlock' }, + { id: 'card-2', typename: 'CardBlock', parentBlockId: 'step-2' }, + { + id: 'heading-2', + typename: 'TypographyBlock', + parentBlockId: 'card-2', + variant: 'h3', + content: 'End' + } + ] as any + } + const result = simplifyJourney(journey) + const parsed = journeySimpleSchema.safeParse(result) + expect(parsed.success).toBe(true) + expect(result.cards).toHaveLength(2) + expect(result.cards[0].backgroundColor).toBe('#1A1A2E') + expect(result.cards[0].backgroundImage).toBeDefined() + expect(result.cards[0].defaultNextCard).toBe('card-end') + // gridView should be present on the poll + const poll = result.cards[0].content.find((b) => b.type === 'poll') + expect(poll).toBeDefined() + if (poll?.type === 'poll') { + expect(poll.gridView).toBe(true) + } + }) }) diff --git a/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.ts b/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.ts index ed613a5dd36..aa7efa1f686 100644 --- a/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.ts +++ b/apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.ts @@ -1,148 +1,343 @@ import type { Prisma } from '@core/prisma/journeys/client' -import { +import type { JourneySimple, + JourneySimpleAction, + JourneySimpleBlock, JourneySimpleCard } from '@core/shared/ai/journeySimpleTypes' +type Block = Prisma.JourneyGetPayload<{ + include: { blocks: { include: { action: true } } } +}>['blocks'][number] + +type StepBlock = Block & { typename: 'StepBlock' } + +/** Generate a content-derived card ID from heading/text content */ +function generateCardIds( + cards: Array<{ heading?: string; firstText?: string }> +): string[] { + const usedIds = new Set() + return cards.map((card) => { + const label = card.heading ?? card.firstText ?? 'untitled' + const slug = + label + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .trim() + .split(/\s+/) + .slice(0, 4) + .join('-') || 'untitled' + let id = `card-${slug}` + let counter = 2 + while (usedIds.has(id)) { + id = `card-${slug}-${counter++}` + } + usedIds.add(id) + return id + }) +} + +/** Map a Prisma Action to a JourneySimpleAction */ +function mapActionReverse( + action: Block['action'], + stepBlocks: StepBlock[], + cardIds: string[] +): JourneySimpleAction | undefined { + if (!action) return undefined + if (action.blockId) { + const idx = stepBlocks.findIndex((s) => s.id === action.blockId) + if (idx >= 0) return { kind: 'navigate', cardId: cardIds[idx] } + } + if (action.url) return { kind: 'url', url: action.url } + if (action.email) return { kind: 'email', email: action.email } + // chatUrl check comes before phone since chatUrl is more specific + if ((action as Record).chatUrl) + return { + kind: 'chat', + chatUrl: (action as Record).chatUrl as string + } + if (action.phone) { + const phoneAction: JourneySimpleAction = { + kind: 'phone', + phone: action.phone, + ...(action.countryCode ? { countryCode: action.countryCode } : {}), + ...(action.contactAction === 'call' || action.contactAction === 'text' + ? { contactAction: action.contactAction } + : {}) + } + return phoneAction + } + return undefined +} + +/** Sort blocks by parentOrder (null → Infinity) */ +function sortByParentOrder(blocks: Block[]): Block[] { + return [...blocks].sort( + (a, b) => + (a.parentOrder ?? Infinity) - (b.parentOrder ?? Infinity) + ) +} + export function simplifyJourney( journey: Prisma.JourneyGetPayload<{ include: { blocks: { include: { action: true } } } }> ): JourneySimple { const stepBlocks = journey.blocks.filter( - (block) => block.typename === 'StepBlock' + (block): block is Block & { typename: 'StepBlock' } => + block.typename === 'StepBlock' ) - const cards = stepBlocks.map((stepBlock, index) => { - const cardBlock = journey.blocks.filter( - (block) => block.parentBlockId === stepBlock.id - )[0] - if (!cardBlock) throw new Error('Card block not found') + // Pre-compute card metadata for ID generation + const cardMeta = stepBlocks.map((stepBlock) => { + const cardBlock = journey.blocks.find( + (b) => b.parentBlockId === stepBlock.id + ) + if (!cardBlock) return { heading: undefined, firstText: undefined } + const children = sortByParentOrder( + journey.blocks.filter((b) => b.parentBlockId === cardBlock.id) + ) + const headingBlock = children.find( + (b) => + b.typename === 'TypographyBlock' && + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(b.variant ?? '') + ) + const textBlock = children.find( + (b) => + b.typename === 'TypographyBlock' && + !['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(b.variant ?? '') + ) + return { + heading: headingBlock?.content ?? undefined, + firstText: textBlock?.content ?? undefined + } + }) + + const cardIds = generateCardIds(cardMeta) - const childBlocks = journey.blocks.filter( - (block) => block.parentBlockId === cardBlock.id + const cards: JourneySimpleCard[] = stepBlocks.map((stepBlock, index) => { + const cardBlock = journey.blocks.find( + (b) => b.parentBlockId === stepBlock.id ) + if (!cardBlock) throw new Error('Card block not found') - // --- VIDEO BLOCK HANDLING --- - const videoBlock = childBlocks.find( - (block) => block.typename === 'VideoBlock' && block.source === 'youTube' + const childBlocks = sortByParentOrder( + journey.blocks.filter((b) => b.parentBlockId === cardBlock.id) ) - if (videoBlock) { - const card: JourneySimpleCard = { - id: `card-${index + 1}`, - x: stepBlock.x ?? 0, - y: stepBlock.y ?? 0, - video: { - url: `https://youtube.com/watch?v=${videoBlock.videoId}`, - startAt: videoBlock.startAt ?? undefined, - endAt: videoBlock.endAt ?? undefined + + // Build content array from child blocks + const content: JourneySimpleBlock[] = [] + + for (const block of childBlocks) { + // Skip cover blocks (handled separately as backgroundImage/backgroundVideo) + if (block.id === cardBlock.coverBlockId) continue + + switch (block.typename) { + case 'TypographyBlock': { + const variant = block.variant ?? 'body1' + const isHeading = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes( + variant + ) + if (isHeading) { + content.push({ + type: 'heading', + text: block.content ?? '', + variant: variant as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + }) + } else { + content.push({ + type: 'text', + text: block.content ?? '', + variant: variant as + | 'body1' + | 'body2' + | 'subtitle1' + | 'subtitle2' + | 'caption' + | 'overline' + }) + } + break } - } - if (stepBlock.nextBlockId) { - const nextStepBlockIndex = stepBlocks.findIndex( - (s) => s.id === stepBlock.nextBlockId - ) - if (nextStepBlockIndex >= 0) { - card.defaultNextCard = `card-${nextStepBlockIndex + 1}` + + case 'ButtonBlock': { + const action = mapActionReverse( + block.action, + stepBlocks, + cardIds + ) + if (action) { + content.push({ + type: 'button', + text: block.label ?? '', + action + }) + } + break } - } - return card - } - // --- NON-VIDEO CARDS (existing logic) --- - const card: JourneySimpleCard = { - id: `card-${index + 1}`, - x: stepBlock.x ?? 0, - y: stepBlock.y ?? 0 - } + case 'ImageBlock': { + content.push({ + type: 'image', + src: block.src ?? '', + alt: block.alt ?? '', + ...(block.width != null ? { width: block.width } : {}), + ...(block.height != null ? { height: block.height } : {}), + ...(block.blurhash ? { blurhash: block.blurhash } : {}) + }) + break + } - const headingBlock = childBlocks.find( - (block) => block.typename === 'TypographyBlock' && block.variant === 'h3' - ) - if (headingBlock) card.heading = headingBlock.content ?? undefined + case 'VideoBlock': { + if (block.source === 'youTube' && block.videoId) { + content.push({ + type: 'video', + url: `https://youtube.com/watch?v=${block.videoId}`, + ...(block.startAt != null ? { startAt: block.startAt } : {}), + ...(block.endAt != null ? { endAt: block.endAt } : {}) + }) + } + break + } - const textBlock = childBlocks.find( - (block) => - block.typename === 'TypographyBlock' && block.variant === 'body1' - ) - if (textBlock) card.text = textBlock.content ?? undefined + case 'RadioQuestionBlock': { + const optionBlocks = sortByParentOrder( + journey.blocks.filter( + (b) => + b.typename === 'RadioOptionBlock' && + b.parentBlockId === block.id + ) + ) + const options = optionBlocks + .map((opt) => { + const action = mapActionReverse( + opt.action, + stepBlocks, + cardIds + ) + if (!action) return null + return { text: opt.label ?? '', action } + }) + .filter( + (o): o is { text: string; action: JourneySimpleAction } => + o != null + ) + if (options.length >= 2) { + content.push({ + type: 'poll', + gridView: block.gridView === true, + options + } as JourneySimpleBlock) + } + break + } - const buttonBlock = childBlocks.find( - (block) => block.typename === 'ButtonBlock' - ) - if (buttonBlock) { - const nextStepBlockIndex = buttonBlock.action?.blockId - ? stepBlocks.findIndex((s) => s.id === buttonBlock.action?.blockId) - : -1 - card.button = { - text: buttonBlock.label ?? '', - nextCard: - nextStepBlockIndex >= 0 - ? `card-${nextStepBlockIndex + 1}` - : undefined, - url: buttonBlock.action?.url ?? undefined - } - } + case 'MultiselectBlock': { + const optionBlocks = sortByParentOrder( + journey.blocks.filter( + (b) => + b.typename === 'MultiselectOptionBlock' && + b.parentBlockId === block.id + ) + ) + const options = optionBlocks.map((opt) => opt.label ?? '') + if (options.length >= 2) { + content.push({ + type: 'multiselect', + ...(block.min != null ? { min: block.min } : {}), + ...(block.max != null ? { max: block.max } : {}), + options + }) + } + break + } - const pollBlock = childBlocks.find( - (block) => block.typename === 'RadioQuestionBlock' - ) - if (pollBlock) { - const pollOptions = journey.blocks.filter( - (block) => - block.typename === 'RadioOptionBlock' && - block.parentBlockId === pollBlock.id - ) - card.poll = pollOptions.map((option) => { - const nextStepBlockIndex = option.action?.blockId - ? stepBlocks.findIndex((s) => s.id === option.action?.blockId) - : -1 - return { - text: option.label ?? '', - nextCard: - nextStepBlockIndex >= 0 - ? `card-${nextStepBlockIndex + 1}` - : undefined, - url: option.action?.url ?? undefined + case 'TextResponseBlock': { + const inputTypeMap: Record = { + freeForm: 'freeForm', + name: 'name', + email: 'email', + phone: 'phone' + } + content.push({ + type: 'textInput', + label: block.label ?? '', + ...(block.type && inputTypeMap[block.type] + ? { + inputType: inputTypeMap[block.type] as + | 'freeForm' + | 'name' + | 'email' + | 'phone' + } + : {}), + ...(block.placeholder ? { placeholder: block.placeholder } : {}), + ...(block.hint ? { hint: block.hint } : {}), + ...(block.required === true ? { required: true } : {}) + } as JourneySimpleBlock) + break } - }) - } - const imageBlock = childBlocks.find( - (block) => - block.typename === 'ImageBlock' && block.id != cardBlock.coverBlockId - ) - if (imageBlock) { - card.image = { - src: imageBlock.src ?? '', - alt: imageBlock.alt ?? '', - width: imageBlock.width ?? 0, - height: imageBlock.height ?? 0, - blurhash: imageBlock.blurhash ?? '' + case 'SpacerBlock': { + if (block.spacing != null && block.spacing > 0) { + content.push({ + type: 'spacer', + spacing: block.spacing + }) + } + break + } } } + // Build card + const card: JourneySimpleCard = { + id: cardIds[index], + ...(stepBlock.x != null ? { x: stepBlock.x } : {}), + ...(stepBlock.y != null ? { y: stepBlock.y } : {}), + ...(cardBlock.backgroundColor + ? { backgroundColor: cardBlock.backgroundColor } + : {}), + content + } + + // Background image (from cover block) if (cardBlock.coverBlockId) { - const bgImageBlock = journey.blocks.find( - (block) => block.id === cardBlock.coverBlockId + const coverBlock = journey.blocks.find( + (b) => b.id === cardBlock.coverBlockId ) - if (bgImageBlock && bgImageBlock.typename === 'ImageBlock') { + if (coverBlock?.typename === 'ImageBlock') { card.backgroundImage = { - src: bgImageBlock.src ?? '', - alt: bgImageBlock.alt ?? '', - width: bgImageBlock.width ?? 0, - height: bgImageBlock.height ?? 0, - blurhash: bgImageBlock.blurhash ?? '' + src: coverBlock.src ?? '', + alt: coverBlock.alt ?? '', + ...(coverBlock.width != null ? { width: coverBlock.width } : {}), + ...(coverBlock.height != null + ? { height: coverBlock.height } + : {}), + ...(coverBlock.blurhash ? { blurhash: coverBlock.blurhash } : {}) + } + } else if ( + coverBlock?.typename === 'VideoBlock' && + coverBlock.source === 'youTube' && + coverBlock.videoId + ) { + card.backgroundVideo = { + url: `https://youtube.com/watch?v=${coverBlock.videoId}`, + ...(coverBlock.startAt != null + ? { startAt: coverBlock.startAt } + : {}), + ...(coverBlock.endAt != null ? { endAt: coverBlock.endAt } : {}) } } } + // Default next card if (stepBlock.nextBlockId) { - const nextStepBlockIndex = stepBlocks.findIndex( + const nextIdx = stepBlocks.findIndex( (s) => s.id === stepBlock.nextBlockId ) - if (nextStepBlockIndex >= 0) { - card.defaultNextCard = `card-${nextStepBlockIndex + 1}` + if (nextIdx >= 0) { + card.defaultNextCard = cardIds[nextIdx] } } diff --git a/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.spec.ts b/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.spec.ts index f8d7ca8dbe5..52b0d3f9a26 100644 --- a/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.spec.ts +++ b/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.spec.ts @@ -14,13 +14,7 @@ jest.mock('../../../utils/generateBlurhashAndMetadataFromUrl', () => ({ }) })) -// Mock environment variables -const originalEnv = process.env - -// Mock ApolloClient jest.mock('@apollo/client') - -// Mock node-fetch jest.mock('node-fetch') const mockFetch = require('node-fetch') as jest.MockedFunction @@ -37,6 +31,7 @@ const txMock = { describe('updateSimpleJourney', () => { const journeyId = 'jid' + const simple: JourneySimpleUpdate = { title: 'Journey', description: 'desc', @@ -45,26 +40,36 @@ describe('updateSimpleJourney', () => { id: 'card-1', x: 0, y: 0, - heading: 'Heading', - text: 'Text', - image: { - src: 'https://images.unsplash.com/photo-1601142634808-38923eb7c560?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80', - alt: 'alt', - width: 100, - height: 100, - blurhash: '' - }, - poll: [ - { text: 'Option 1', nextCard: 'card-2' }, - { text: 'Option 2', url: 'https://example.com' } + content: [ + { type: 'heading', text: 'Heading', variant: 'h3' as const }, + { type: 'text', text: 'Body text', variant: 'body1' as const }, + { + type: 'image', + src: 'https://images.unsplash.com/photo-123', + alt: 'alt', + width: 100, + height: 100, + blurhash: '' + }, + { + type: 'button', + text: 'Next', + action: { kind: 'navigate', cardId: 'card-2' } + }, + { + type: 'poll', gridView: false, + options: [ + { + text: 'Option 1', + action: { kind: 'navigate', cardId: 'card-2' } + }, + { text: 'Option 2', action: { kind: 'url', url: 'https://example.com' } } + ] + } ], - button: { text: 'Next', nextCard: 'card-2' }, backgroundImage: { - src: 'https://images.unsplash.com/photo-1601142634808-38923eb7c560?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80', - alt: 'bg', - width: 200, - height: 200, - blurhash: '' + src: 'https://images.unsplash.com/photo-456', + alt: 'bg' }, defaultNextCard: 'card-2' }, @@ -72,15 +77,23 @@ describe('updateSimpleJourney', () => { id: 'card-2', x: 100, y: 100, - heading: 'Second', - text: 'Second text', + content: [ + { type: 'heading', text: 'Second', variant: 'h3' as const }, + { type: 'text', text: 'Second text', variant: 'body1' as const } + ], defaultNextCard: 'card-3' }, { id: 'card-3', x: 200, y: 200, - button: { text: 'End', url: 'https://example.com' } + content: [ + { + type: 'button', + text: 'End', + action: { kind: 'url', url: 'https://example.com' } + } + ] } ] } @@ -98,7 +111,7 @@ describe('updateSimpleJourney', () => { items: [ { contentDetails: { - duration: 'PT3M45S' // 3 minutes 45 seconds = 225 seconds + duration: 'PT3M45S' // 225 seconds } } ] @@ -106,12 +119,12 @@ describe('updateSimpleJourney', () => { } as any) }) - it('wraps all operations in a transaction', async () => { + it('should wrap all operations in a transaction', async () => { await updateSimpleJourney(journeyId, simple) expect(prismaMock.$transaction).toHaveBeenCalled() }) - it('marks all non-deleted blocks as deleted', async () => { + it('should mark all non-deleted blocks as deleted', async () => { await updateSimpleJourney(journeyId, simple) expect(txMock.block.updateMany).toHaveBeenCalledWith({ where: { journeyId, deletedAt: null }, @@ -119,7 +132,7 @@ describe('updateSimpleJourney', () => { }) }) - it('updates journey title and description', async () => { + it('should update journey title and description', async () => { await updateSimpleJourney(journeyId, simple) expect(txMock.journey.update).toHaveBeenCalledWith({ where: { id: journeyId }, @@ -127,482 +140,559 @@ describe('updateSimpleJourney', () => { }) }) - it('creates StepBlocks and CardBlocks for each card', async () => { + it('should create StepBlocks and CardBlocks for each card', async () => { await updateSimpleJourney(journeyId, simple) - // Should create 3 StepBlocks and 3 CardBlocks const stepCalls = txMock.block.create.mock.calls.filter( ([data]: [any]) => data.data.typename === 'StepBlock' ) const cardCalls = txMock.block.create.mock.calls.filter( ([data]: [any]) => data.data.typename === 'CardBlock' ) - expect(stepCalls.length).toBe(3) - expect(cardCalls.length).toBe(3) + expect(stepCalls).toHaveLength(3) + expect(cardCalls).toHaveLength(3) }) - it('creates content blocks for heading, text, image, poll, button, backgroundImage, defaultNextCard', async () => { + it('should create content blocks from content array (heading, text, button, image)', async () => { await updateSimpleJourney(journeyId, simple) - // Check for TypographyBlock, ImageBlock, RadioQuestionBlock, RadioOptionBlock, ButtonBlock const types = txMock.block.create.mock.calls.map( ([data]: [any]) => data.data.typename ) expect(types).toContain('TypographyBlock') expect(types).toContain('ImageBlock') + expect(types).toContain('ButtonBlock') expect(types).toContain('RadioQuestionBlock') expect(types).toContain('RadioOptionBlock') - expect(types).toContain('ButtonBlock') }) - it('creates VideoBlock for video cards with full timing', async () => { - const videoJourney: JourneySimpleUpdate = { - title: 'Video Journey', - description: 'A journey with video', + it('should handle all 5 action kinds (navigate, url, email, chat, phone)', async () => { + const actionJourney: JourneySimpleUpdate = { + title: 'Actions', + description: 'All action kinds', cards: [ { - id: 'video-card-1', + id: 'card-1', x: 0, y: 0, - video: { - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: 30, - endAt: 120 - }, - defaultNextCard: 'card-2' + content: [ + { + type: 'button', + text: 'Navigate', + action: { kind: 'navigate', cardId: 'card-2' } + }, + { + type: 'button', + text: 'URL', + action: { kind: 'url', url: 'https://example.com' } + }, + { + type: 'button', + text: 'Email', + action: { kind: 'email', email: 'test@example.com' } + }, + { + type: 'button', + text: 'Chat', + action: { kind: 'chat', chatUrl: 'https://wa.me/123' } + }, + { + type: 'button', + text: 'Phone', + action: { + kind: 'phone', + phone: '+15551234567', + countryCode: 'US', + contactAction: 'call' + } + } + ] }, { id: 'card-2', - x: 0, - y: 400, - button: { text: 'End', url: 'https://example.com' } + x: 300, + y: 0, + content: [{ type: 'heading', text: 'Done', variant: 'h3' as const }] } ] } - await updateSimpleJourney(journeyId, videoJourney) + await updateSimpleJourney(journeyId, actionJourney) - const videoCalls = txMock.block.create.mock.calls.filter( - ([data]: [any]) => data.data.typename === 'VideoBlock' + const buttonCalls = txMock.block.create.mock.calls.filter( + ([data]: [any]) => data.data.typename === 'ButtonBlock' ) - expect(videoCalls.length).toBe(1) + expect(buttonCalls).toHaveLength(5) - const videoBlockData = videoCalls[0][0].data - expect(videoBlockData.source).toBe('youTube') - expect(videoBlockData.videoId).toBe('dQw4w9WgXcQ') - expect(videoBlockData.startAt).toBe(30) - expect(videoBlockData.endAt).toBe(120) + // navigate + expect(buttonCalls[0][0].data.action).toEqual({ + create: { blockId: 'mock-block-id' } + }) + // url + expect(buttonCalls[1][0].data.action).toEqual({ + create: { url: 'https://example.com' } + }) + // email + expect(buttonCalls[2][0].data.action).toEqual({ + create: { email: 'test@example.com' } + }) + // chat + expect(buttonCalls[3][0].data.action).toEqual({ + create: { chatUrl: 'https://wa.me/123' } + }) + // phone + expect(buttonCalls[4][0].data.action).toEqual({ + create: { + phone: '+15551234567', + countryCode: 'US', + contactAction: 'call' + } + }) }) - it('creates VideoBlock for video cards with default timing (fetches from YouTube API)', async () => { - const videoJourney: JourneySimpleUpdate = { - title: 'Video Journey', - description: 'A journey with video', + it('should create poll with options and actions', async () => { + const pollJourney: JourneySimpleUpdate = { + title: 'Poll', + description: 'Poll test', cards: [ { - id: 'video-card-1', + id: 'card-1', x: 0, y: 0, - video: { - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' - // No startAt/endAt provided - }, - defaultNextCard: 'card-2' + content: [ + { + type: 'poll', + gridView: true, + options: [ + { + text: 'Option A', + action: { kind: 'navigate', cardId: 'card-2' } + }, + { + text: 'Option B', + action: { kind: 'url', url: 'https://example.com' } + } + ] + } + ] }, { id: 'card-2', - x: 0, - y: 400, - button: { text: 'End', url: 'https://example.com' } + x: 300, + y: 0, + content: [{ type: 'heading', text: 'Done', variant: 'h3' as const }] } ] } - await updateSimpleJourney(journeyId, videoJourney) + await updateSimpleJourney(journeyId, pollJourney) - // Should call YouTube API to get duration - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining('https://www.googleapis.com/youtube/v3/videos') + const radioCalls = txMock.block.create.mock.calls.filter( + ([data]: [any]) => data.data.typename === 'RadioQuestionBlock' ) + expect(radioCalls).toHaveLength(1) + expect(radioCalls[0][0].data.gridView).toBe(true) - const videoCalls = txMock.block.create.mock.calls.filter( - ([data]: [any]) => data.data.typename === 'VideoBlock' + const optionCalls = txMock.block.create.mock.calls.filter( + ([data]: [any]) => data.data.typename === 'RadioOptionBlock' ) - const videoBlockData = videoCalls[0][0].data - expect(videoBlockData.startAt).toBe(0) // Default startAt - expect(videoBlockData.endAt).toBe(225) // Duration from mocked API response (3m45s) + expect(optionCalls).toHaveLength(2) + expect(optionCalls[0][0].data.label).toBe('Option A') + expect(optionCalls[1][0].data.label).toBe('Option B') + expect(optionCalls[1][0].data.action).toEqual({ + create: { url: 'https://example.com' } + }) }) - it('extracts videoId from various YouTube URL formats', async () => { - const testUrls = [ - 'https://youtube.com/watch?v=dQw4w9WgXcQ', - 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s', - 'https://youtu.be/dQw4w9WgXcQ', - 'https://youtube.com/embed/dQw4w9WgXcQ', - 'dQw4w9WgXcQ' // Direct videoId - ] - - for (const url of testUrls) { - const videoJourney: JourneySimpleUpdate = { - title: 'Video Journey', - description: 'Test URL extraction', - cards: [ - { - id: 'video-card-1', - x: 0, - y: 0, - video: { url }, - defaultNextCard: 'card-2' - }, - { - id: 'card-2', - x: 0, - y: 400, - button: { text: 'End', url: 'https://example.com' } - } - ] - } + it('should create multiselect with option blocks', async () => { + const msJourney: JourneySimpleUpdate = { + title: 'Multiselect', + description: 'MS test', + cards: [ + { + id: 'card-1', + x: 0, + y: 0, + content: [ + { + type: 'multiselect', + min: 1, + max: 3, + options: ['Alpha', 'Beta', 'Gamma'] + } + ] + } + ] + } - jest.clearAllMocks() - txMock.block.create.mockResolvedValue({ id: 'mock-block-id' } as any) + await updateSimpleJourney(journeyId, msJourney) - await updateSimpleJourney(journeyId, videoJourney) + const msCalls = txMock.block.create.mock.calls.filter( + ([data]: [any]) => data.data.typename === 'MultiselectBlock' + ) + expect(msCalls).toHaveLength(1) + expect(msCalls[0][0].data.min).toBe(1) + expect(msCalls[0][0].data.max).toBe(3) - const videoCalls = txMock.block.create.mock.calls.filter( - ([data]: [any]) => data.data.typename === 'VideoBlock' - ) - expect(videoCalls.length).toBe(1) - expect(videoCalls[0][0].data.videoId).toBe('dQw4w9WgXcQ') - } + const optionCalls = txMock.block.create.mock.calls.filter( + ([data]: [any]) => data.data.typename === 'MultiselectOptionBlock' + ) + expect(optionCalls).toHaveLength(3) + expect(optionCalls[0][0].data.label).toBe('Alpha') + expect(optionCalls[1][0].data.label).toBe('Beta') + expect(optionCalls[2][0].data.label).toBe('Gamma') }) - it('throws error for invalid YouTube URLs', async () => { - const videoJourney: JourneySimpleUpdate = { - title: 'Invalid Video Journey', - description: 'A journey with invalid video URL', + it('should create textInput block', async () => { + const tiJourney: JourneySimpleUpdate = { + title: 'TextInput', + description: 'TI test', cards: [ { - id: 'video-card-1', + id: 'card-1', x: 0, y: 0, - video: { - url: 'https://vimeo.com/123456789' // Not YouTube - }, - defaultNextCard: 'card-2' - }, - { - id: 'card-2', - x: 0, - y: 400, - button: { text: 'End', url: 'https://example.com' } + content: [ + { + type: 'textInput', + label: 'Your name', + inputType: 'name', + placeholder: 'Enter name', + hint: 'First and last', + required: true + } + ] } ] } - await expect(updateSimpleJourney(journeyId, videoJourney)).rejects.toThrow( - 'Invalid YouTube video URL' + await updateSimpleJourney(journeyId, tiJourney) + + const tiCalls = txMock.block.create.mock.calls.filter( + ([data]: [any]) => data.data.typename === 'TextResponseBlock' ) + expect(tiCalls).toHaveLength(1) + expect(tiCalls[0][0].data).toMatchObject({ + label: 'Your name', + type: 'name', + placeholder: 'Enter name', + hint: 'First and last', + required: true + }) }) - it('throws error when YouTube API fails', async () => { - mockFetch.mockResolvedValueOnce({ - json: () => - Promise.resolve({ - items: [] // No video found - }) - } as any) - - const videoJourney: JourneySimpleUpdate = { - title: 'Video Journey', - description: 'A journey with video', + it('should create spacer block', async () => { + const spacerJourney: JourneySimpleUpdate = { + title: 'Spacer', + description: 'Spacer test', cards: [ { - id: 'video-card-1', + id: 'card-1', x: 0, y: 0, - video: { - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' - }, - defaultNextCard: 'card-2' - }, - { - id: 'card-2', - x: 0, - y: 400, - button: { text: 'End', url: 'https://example.com' } + content: [{ type: 'spacer', spacing: 24 }] } ] } - await expect(updateSimpleJourney(journeyId, videoJourney)).rejects.toThrow( - 'Could not fetch video duration' + await updateSimpleJourney(journeyId, spacerJourney) + + const spacerCalls = txMock.block.create.mock.calls.filter( + ([data]: [any]) => data.data.typename === 'SpacerBlock' ) + expect(spacerCalls).toHaveLength(1) + expect(spacerCalls[0][0].data.spacing).toBe(24) }) - it('creates video block with navigation action when defaultNextCard is provided', async () => { - const videoJourney: JourneySimpleUpdate = { - title: 'Video Journey', - description: 'A journey with video', + it('should create backgroundImage as cover block', async () => { + await updateSimpleJourney(journeyId, simple) + + const imageCalls = txMock.block.create.mock.calls.filter( + ([data]: [any]) => + data.data.typename === 'ImageBlock' && data.data.parentOrder == null + ) + expect(imageCalls.length).toBeGreaterThanOrEqual(1) + + expect(txMock.block.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'mock-block-id' }, + data: { coverBlockId: 'mock-block-id' } + }) + ) + }) + + it('should create backgroundVideo as cover block', async () => { + const bgVideoJourney: JourneySimpleUpdate = { + title: 'BgVideo', + description: 'Background video test', cards: [ { - id: 'video-card-1', + id: 'card-1', x: 0, y: 0, - video: { + content: [{ type: 'heading', text: 'Hello', variant: 'h3' as const }], + backgroundVideo: { url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: 0, + startAt: 10, endAt: 60 - }, - defaultNextCard: 'card-2' - }, - { - id: 'card-2', - x: 0, - y: 400, - button: { text: 'End', url: 'https://example.com' } + } } ] } - await updateSimpleJourney(journeyId, videoJourney) + await updateSimpleJourney(journeyId, bgVideoJourney) const videoCalls = txMock.block.create.mock.calls.filter( ([data]: [any]) => data.data.typename === 'VideoBlock' ) - const videoBlockData = videoCalls[0][0].data - expect(videoBlockData.action).toBeDefined() - expect(videoBlockData.action.create.blockId).toBeDefined() + expect(videoCalls).toHaveLength(1) + expect(videoCalls[0][0].data.videoId).toBe('dQw4w9WgXcQ') + expect(videoCalls[0][0].data.startAt).toBe(10) + expect(videoCalls[0][0].data.endAt).toBe(60) + + expect(txMock.block.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { coverBlockId: 'mock-block-id' } + }) + ) }) - it('does not create other content blocks for video cards', async () => { - const videoJourney: JourneySimpleUpdate = { - title: 'Video Journey', - description: 'A journey with video only', + it('should auto-assign x/y when omitted', async () => { + const autoXYJourney: JourneySimpleUpdate = { + title: 'AutoXY', + description: 'Auto layout test', cards: [ { - id: 'video-card-1', - x: 0, - y: 0, - video: { - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' - }, - defaultNextCard: 'card-2' + id: 'card-1', + content: [{ type: 'heading', text: 'First', variant: 'h3' as const }] }, { id: 'card-2', - x: 0, - y: 400, - button: { text: 'End', url: 'https://example.com' } + content: [{ type: 'heading', text: 'Second', variant: 'h3' as const }] + }, + { + id: 'card-3', + content: [{ type: 'heading', text: 'Third', variant: 'h3' as const }] } ] } - await updateSimpleJourney(journeyId, videoJourney) + await updateSimpleJourney(journeyId, autoXYJourney) - const allCalls = txMock.block.create.mock.calls - const contentTypes = allCalls.map(([data]: [any]) => data.data.typename) - - // Should have StepBlocks, CardBlocks, VideoBlock, and ButtonBlock - // Should NOT have TypographyBlock, ImageBlock, etc. for the video card - expect(contentTypes.filter((type) => type === 'VideoBlock')).toHaveLength(1) - expect(contentTypes.filter((type) => type === 'ButtonBlock')).toHaveLength( - 1 - ) // Only for card-2 - expect( - contentTypes.filter((type) => type === 'TypographyBlock') - ).toHaveLength(0) - expect(contentTypes.filter((type) => type === 'ImageBlock')).toHaveLength(0) + const stepCalls = txMock.block.create.mock.calls.filter( + ([data]: [any]) => data.data.typename === 'StepBlock' + ) + expect(stepCalls[0][0].data.x).toBe(0) + expect(stepCalls[0][0].data.y).toBe(0) + expect(stepCalls[1][0].data.x).toBe(300) + expect(stepCalls[1][0].data.y).toBe(0) + expect(stepCalls[2][0].data.x).toBe(600) + expect(stepCalls[2][0].data.y).toBe(0) }) - it('parses ISO8601 duration correctly', async () => { - // Test different duration formats - const durations = [ - { iso: 'PT3M45S', expected: 225 }, // 3m45s - { iso: 'PT1H30M', expected: 5400 }, // 1h30m - { iso: 'PT45S', expected: 45 }, // 45s - { iso: 'PT2H', expected: 7200 }, // 2h - { iso: 'PT10M', expected: 600 } // 10m - ] - - for (const { iso, expected } of durations) { - mockFetch.mockResolvedValueOnce({ - json: () => - Promise.resolve({ - items: [ - { - contentDetails: { - duration: iso - } - } - ] - }) - } as any) - - const videoJourney: JourneySimpleUpdate = { - title: 'Duration Test', - description: 'Testing duration parsing', - cards: [ - { - id: 'video-card', - x: 0, - y: 0, - video: { url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' }, // Use valid video ID - defaultNextCard: 'end-card' - }, - { - id: 'end-card', - x: 0, - y: 400, - button: { text: 'End', url: 'https://example.com' } - } - ] - } - - jest.clearAllMocks() - txMock.block.create.mockResolvedValue({ id: 'mock-id' } as any) + it('should create VideoBlock for video content', async () => { + const videoJourney: JourneySimpleUpdate = { + title: 'Video', + description: 'Video test', + cards: [ + { + id: 'card-1', + x: 0, + y: 0, + content: [ + { + type: 'video', + url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', + startAt: 30, + endAt: 120 + } + ] + } + ] + } - await updateSimpleJourney(journeyId, videoJourney) + await updateSimpleJourney(journeyId, videoJourney) - const videoCalls = txMock.block.create.mock.calls.filter( - ([data]: [any]) => data.data.typename === 'VideoBlock' - ) - expect(videoCalls[0][0].data.endAt).toBe(expected) - } + const videoCalls = txMock.block.create.mock.calls.filter( + ([data]: [any]) => data.data.typename === 'VideoBlock' + ) + expect(videoCalls).toHaveLength(1) + expect(videoCalls[0][0].data).toMatchObject({ + typename: 'VideoBlock', + videoId: 'dQw4w9WgXcQ', + source: 'youTube', + autoplay: true, + startAt: 30, + endAt: 120 + }) }) - it('handles missing optional fields gracefully', async () => { - const minimal: JourneySimpleUpdate = { - title: 't', - description: 'd', + it('should fetch YouTube duration when endAt not provided', async () => { + const videoJourney: JourneySimpleUpdate = { + title: 'Video', + description: 'Duration fetch test', cards: [ - { id: 'c1', x: 0, y: 0 }, { - id: 'c2', + id: 'card-1', x: 0, - y: 400, - button: { text: 'End', url: 'https://example.com' } + y: 0, + content: [ + { + type: 'video', + url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' + } + ] } ] } - await expect(updateSimpleJourney(journeyId, minimal)).resolves.not.toThrow() + + await updateSimpleJourney(journeyId, videoJourney) + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('https://www.googleapis.com/youtube/v3/videos') + ) + + const videoCalls = txMock.block.create.mock.calls.filter( + ([data]: [any]) => data.data.typename === 'VideoBlock' + ) + expect(videoCalls[0][0].data.startAt).toBe(0) + expect(videoCalls[0][0].data.endAt).toBe(225) // PT3M45S }) describe('cloudflare upload', () => { - it('uploads invalid image URLs to Cloudflare and uses the returned URL', async () => { - // Mock Apollo mutation to return a specific image ID - const mockImageId = 'test-cloudflare-image-id' + it('should handle valid image URLs without Cloudflare upload', async () => { jest.spyOn(ApolloClient.prototype, 'mutate').mockImplementation( async () => await Promise.resolve({ data: { - createCloudflareUploadByUrl: { - id: mockImageId - } + createCloudflareUploadByUrl: { id: 'unused' } } }) ) - // Test with invalid URLs that should trigger upload - const testData = { - ...simple, + const validImageJourney: JourneySimpleUpdate = { + title: 'Valid Images', + description: 'No upload needed', cards: [ { - ...simple.cards[0], - image: { - src: 'https://invalid-domain.com/image.jpg', // Invalid URL - alt: 'test', - width: 150, - height: 150, - blurhash: '' - }, + id: 'card-1', + x: 0, + y: 0, + content: [ + { + type: 'image', + src: 'https://imagedelivery.net/test/valid-id/public', + alt: 'valid' + } + ], backgroundImage: { - src: 'https://another-invalid-domain.com/bg.jpg', // Invalid URL - alt: 'bg', - width: 200, - height: 200, - blurhash: '' + src: 'https://images.unsplash.com/photo-123', + alt: 'bg' } } ] } - await updateSimpleJourney(journeyId, testData) - - // Verify Apollo mutation was called for both images - expect(ApolloClient.prototype.mutate).toHaveBeenCalledTimes(2) + await updateSimpleJourney(journeyId, validImageJourney) + expect(ApolloClient.prototype.mutate).not.toHaveBeenCalled() - // Verify the correct Cloudflare URLs were used in block creation - const imageBlockCalls = txMock.block.create.mock.calls.filter( + const imageCalls = txMock.block.create.mock.calls.filter( ([data]: [any]) => data.data.typename === 'ImageBlock' ) - - expect(imageBlockCalls).toHaveLength(2) - expect(imageBlockCalls[0][0].data.src).toBe( - `https://imagedelivery.net/test-cloudflare-account-hash/${mockImageId}/public` + // content image + background image + expect(imageCalls).toHaveLength(2) + expect(imageCalls[0][0].data.src).toBe( + 'https://imagedelivery.net/test/valid-id/public' ) - expect(imageBlockCalls[1][0].data.src).toBe( - `https://imagedelivery.net/test-cloudflare-account-hash/${mockImageId}/public` + expect(imageCalls[1][0].data.src).toBe( + 'https://images.unsplash.com/photo-123' ) - // Verify mocked width, height, and blurhash values are used (from generateBlurhashAndMetadataFromUrl) - for (const call of imageBlockCalls) { - expect(call[0].data.width).toBe(100) - expect(call[0].data.height).toBe(100) - expect(call[0].data.blurhash).toBe('mocked-blurhash') - } }) - it('uses valid image URLs as-is without uploading to Cloudflare', async () => { - // Mock Apollo mutation to not be called - jest.spyOn(ApolloClient.prototype, 'mutate').mockImplementationOnce( + it('should upload invalid image URLs to Cloudflare', async () => { + const mockImageId = 'test-cf-image-id' + jest.spyOn(ApolloClient.prototype, 'mutate').mockImplementation( async () => await Promise.resolve({ data: { - createCloudflareUploadByUrl: { - id: 'test-cloudflare-image-id' - } + createCloudflareUploadByUrl: { id: mockImageId } } }) ) - // Test with valid URLs that should NOT trigger upload - const testData = { - ...simple, + const invalidImageJourney: JourneySimpleUpdate = { + title: 'Invalid Images', + description: 'Upload needed', cards: [ { - ...simple.cards[0], - image: { - src: 'https://imagedelivery.net/test/valid-image-id/public', // Valid URL - alt: 'test', - width: 100, - height: 100, - blurhash: '' - }, + id: 'card-1', + x: 0, + y: 0, + content: [ + { + type: 'image', + src: 'https://unknown-host.com/image.jpg', + alt: 'test' + } + ], backgroundImage: { - src: 'https://images.unsplash.com/photo-123456789', // Valid URL - alt: 'bg', - width: 200, - height: 200, - blurhash: '' + src: 'https://another-invalid.com/bg.jpg', + alt: 'bg' } } ] } - await updateSimpleJourney(journeyId, testData) + await updateSimpleJourney(journeyId, invalidImageJourney) - // Verify Apollo mutation was NOT called - expect(ApolloClient.prototype.mutate).not.toHaveBeenCalled() + expect(ApolloClient.prototype.mutate).toHaveBeenCalledTimes(2) - // Verify the original URLs were used in block creation - const imageBlockCalls = txMock.block.create.mock.calls.filter( + const imageCalls = txMock.block.create.mock.calls.filter( ([data]: [any]) => data.data.typename === 'ImageBlock' ) + expect(imageCalls).toHaveLength(2) + const expectedSrc = `https://imagedelivery.net/test-cloudflare-account-hash/${mockImageId}/public` + expect(imageCalls[0][0].data.src).toBe(expectedSrc) + expect(imageCalls[1][0].data.src).toBe(expectedSrc) - expect(imageBlockCalls).toHaveLength(2) - expect(imageBlockCalls[0][0].data.src).toBe( - 'https://imagedelivery.net/test/valid-image-id/public' - ) - expect(imageBlockCalls[1][0].data.src).toBe( - 'https://images.unsplash.com/photo-123456789' - ) + for (const call of imageCalls) { + expect(call[0].data.width).toBe(100) + expect(call[0].data.height).toBe(100) + expect(call[0].data.blurhash).toBe('mocked-blurhash') + } }) }) + + it('should set defaultNextCard via StepBlock.nextBlockId', async () => { + await updateSimpleJourney(journeyId, simple) + + // card-1 and card-2 both have defaultNextCard set + const stepUpdateCalls = txMock.block.update.mock.calls.filter( + ([data]: [any]) => data.data.nextBlockId != null + ) + expect(stepUpdateCalls.length).toBeGreaterThanOrEqual(2) + expect(stepUpdateCalls[0][0].data.nextBlockId).toBe('mock-block-id') + }) + + it('should throw error for invalid YouTube URLs', async () => { + const invalidVideoJourney: JourneySimpleUpdate = { + title: 'Invalid Video', + description: 'Bad URL', + cards: [ + { + id: 'card-1', + x: 0, + y: 0, + content: [ + { + type: 'video', + url: 'https://vimeo.com/123456789' + } + ] + } + ] + } + + await expect( + updateSimpleJourney(journeyId, invalidVideoJourney) + ).rejects.toThrow('Invalid YouTube video URL') + }) }) diff --git a/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.ts b/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.ts index 5ea3635f77d..5b48be1f959 100644 --- a/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.ts +++ b/apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.ts @@ -3,7 +3,8 @@ import { graphql } from 'gql.tada' import fetch from 'node-fetch' import { prisma } from '@core/prisma/journeys/client' -import { +import type { + JourneySimpleAction, JourneySimpleImage, JourneySimpleUpdate } from '@core/shared/ai/journeySimpleTypes' @@ -12,7 +13,6 @@ import { env } from '../../../env' import { generateBlurhashAndMetadataFromUrl } from '../../../utils/generateBlurhashAndMetadataFromUrl' const ALLOWED_IMAGE_HOSTNAMES = [ - // matches jourenys-admin next.config.js 'localhost', 'unsplash.com', 'images.unsplash.com', @@ -27,15 +27,12 @@ const isValidImageUrl = (url: string): boolean => { try { const parsed = new URL(url) const hostname = parsed.hostname.toLowerCase() - // Check protocol if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false - // Check hostname (allow subdomains) return ALLOWED_IMAGE_HOSTNAMES.some( (allowed) => hostname === allowed || hostname.endsWith(`.${allowed}`) ) } catch { - // Not a valid URL return false } } @@ -62,57 +59,66 @@ const CREATE_CLOUDFLARE_IMAGE = graphql(` } `) -// Helper function to process images -async function processImage(image: JourneySimpleImage) { +export interface ProcessedImage { + src: string + alt: string + width: number + height: number + blurhash: string +} + +export async function processImage(image: JourneySimpleImage): Promise { let src = image.src let blurhash = image.blurhash ?? '' - let width = image.width ?? 1 - let height = image.height ?? 1 + let width = image.width ?? 0 + let height = image.height ?? 0 - // Only upload if src is not already valid if (!isValidImageUrl(src)) { const { data } = await apollo.mutate({ mutation: CREATE_CLOUDFLARE_IMAGE, variables: { url: src } }) - const imageId = data?.createCloudflareUploadByUrl?.id if (imageId != null) { src = `https://imagedelivery.net/${env.CLOUDFLARE_UPLOAD_KEY}/${imageId}/public` - - // Generate blurhash for the uploaded image - const blurhashData = await generateBlurhashAndMetadataFromUrl(src) - blurhash = blurhashData.blurhash - width = blurhashData.width - height = blurhashData.height + const metadata = await generateBlurhashAndMetadataFromUrl(src) + blurhash = metadata.blurhash + width = metadata.width + height = metadata.height } } - return { - src, - blurhash, - width, - height, - alt: image.alt + // Fetch metadata for valid URLs that are missing dimensions or blurhash + if (width <= 1 || height <= 1 || blurhash === '') { + try { + const metadata = await generateBlurhashAndMetadataFromUrl(src) + if (width <= 1) width = metadata.width + if (height <= 1) height = metadata.height + if (blurhash === '') blurhash = metadata.blurhash + } catch { + // Fall back to defaults if metadata fetch fails + if (width <= 1) width = 1920 + if (height <= 1) height = 1080 + } } + + return { src, blurhash, width, height, alt: image.alt } } function extractYouTubeVideoId(url: string): string | null { - // If url is already an 11-char video ID, return as-is if (/^[\w-]{11}$/.test(url)) return url - // Otherwise, try to extract from url const match = url.match( /(?:v=|vi=|youtu\.be\/|\/v\/|embed\/|shorts\/|\/watch\?v=|\/watch\?.+&v=)([\w-]{11})/ ) if (match) return match[1] - // Fallback: try generic 11-char match const generic = url.match(/([\w-]{11})/) return generic ? generic[1] : null } -// parse iso8601 duration function function parseISO8601Duration(duration: string): number { - const match = duration.match(/P(\d+Y)?(\d+W)?(\d+D)?T(\d+H)?(\d+M)?(\d+S)?/) + const match = duration.match( + /P(\d+Y)?(\d+W)?(\d+D)?T(\d+H)?(\d+M)?(\d+S)?/ + ) if (match == null) return 0 const [years, weeks, days, hours, minutes, seconds] = match .slice(1) @@ -125,7 +131,6 @@ function parseISO8601Duration(duration: string): number { ) } -// get youtube video duration async function getYouTubeVideoDuration(videoId: string): Promise { const videosQuery = new URLSearchParams({ part: 'contentDetails', @@ -141,261 +146,362 @@ async function getYouTubeVideoDuration(videoId: string): Promise { return parseISO8601Duration(isoDuration) } +type StepBlockMap = Array<{ + stepBlockId: string + cardBlockId: string + simpleCardId: string +}> + +/** Map a JourneySimpleAction to Prisma action create data */ +function mapAction( + action: JourneySimpleAction, + stepBlocks: StepBlockMap +): Record | undefined { + switch (action.kind) { + case 'navigate': { + const target = stepBlocks.find( + (s) => s.simpleCardId === action.cardId + ) + return target ? { create: { blockId: target.stepBlockId } } : undefined + } + case 'url': + return { create: { url: action.url } } + case 'email': + return { create: { email: action.email } } + case 'chat': + return { create: { chatUrl: action.chatUrl } } + case 'phone': + return { + create: { + phone: action.phone, + ...(action.countryCode ? { countryCode: action.countryCode } : {}), + ...(action.contactAction + ? { contactAction: action.contactAction } + : {}) + } + } + } +} + export async function updateSimpleJourney( journeyId: string, simple: JourneySimpleUpdate -) { - return prisma.$transaction(async (tx) => { - const processedBackgroundImages = new Map() - const processedCardImages = new Map() - - for (const card of simple.cards) { - if (card.backgroundImage != null) { - processedBackgroundImages.set( - card.id, - await processImage(card.backgroundImage) - ) +): Promise { + // --- PHASE 1: Pre-process all images and videos OUTSIDE the transaction --- + const processedImages = new Map() + const processedBgImages = new Map() + const videoMetadata = new Map() // cardId → duration + + for (const card of simple.cards) { + // Content images + for (const block of card.content) { + if (block.type === 'image') { + const key = `${card.id}:${block.src}` + if (!processedImages.has(key)) { + processedImages.set( + key, + await processImage({ src: block.src, alt: block.alt }) + ) + } } - - if (card.image != null) { - processedCardImages.set(card.id, await processImage(card.image)) + if (block.type === 'video') { + const videoId = extractYouTubeVideoId(block.url) + if (videoId) { + const duration = await getYouTubeVideoDuration(videoId) + videoMetadata.set(`${card.id}:${block.url}`, duration) + } } } + // Background image + if (card.backgroundImage) { + processedBgImages.set(card.id, await processImage(card.backgroundImage)) + } + // Background video + if (card.backgroundVideo) { + const videoId = extractYouTubeVideoId(card.backgroundVideo.url) + if (videoId) { + const duration = await getYouTubeVideoDuration(videoId) + videoMetadata.set(`bg:${card.id}`, duration) + } + } + } - // Mark all non-deleted blocks for this journey as deleted + // --- PHASE 2: Transaction — only DB writes --- + await prisma.$transaction(async (tx) => { + // Soft-delete all existing blocks await tx.block.updateMany({ where: { journeyId, deletedAt: null }, data: { deletedAt: new Date().toISOString() } }) - // Update journey title and description + // Update journey title/description await tx.journey.update({ where: { id: journeyId }, - data: { - title: simple.title, - description: simple.description - } + data: { title: simple.title, description: simple.description } }) - // Array of { id: stepBlockId, cardId: cardBlockId } - const stepBlocks: { - stepBlockId: string - cardBlockId: string - simpleCardId: string - }[] = [] + // Create StepBlocks + CardBlocks + const stepBlocks: StepBlockMap = [] - // 1. Create StepBlocks and CardBlocks - for (const [i, simpleCard] of simple.cards.entries()) { + for (const [i, card] of simple.cards.entries()) { const stepBlock = await tx.block.create({ data: { journeyId, typename: 'StepBlock', parentOrder: i, - x: simpleCard.x ?? 0, - y: simpleCard.y ?? 0 + x: card.x ?? i * 300, + y: card.y ?? 0 } }) - // Create CardBlock as child of StepBlock const cardBlock = await tx.block.create({ data: { journeyId, typename: 'CardBlock', parentBlockId: stepBlock.id, - parentOrder: 0 // always first child + parentOrder: 0, + ...(card.backgroundColor + ? { backgroundColor: card.backgroundColor } + : {}) } }) stepBlocks.push({ stepBlockId: stepBlock.id, cardBlockId: cardBlock.id, - simpleCardId: simpleCard.id + simpleCardId: card.id }) } - // 2. For each card, create content blocks as children of CardBlock + // Create content blocks for each card for (const card of simple.cards) { - const stepBlockEntry = stepBlocks.find((s) => s.simpleCardId === card.id) - if (!stepBlockEntry) { - // No matching step block found for this card, skip to next card - continue - } - const { stepBlockId, cardBlockId } = stepBlockEntry - let parentOrder = 0 - - if (card.video != null) { - const nextStepBlock = - card.defaultNextCard != null - ? stepBlocks.find((s) => s.simpleCardId === card.defaultNextCard) - : undefined - const videoId = extractYouTubeVideoId(card.video.url) - if (videoId == null) { - throw new Error('Invalid YouTube video URL') - } - const videoDuration = await getYouTubeVideoDuration(videoId) - await tx.block.create({ - data: { - journeyId, - typename: 'VideoBlock', - parentBlockId: cardBlockId, - parentOrder: parentOrder++, - videoId, - source: 'youTube', - autoplay: true, - startAt: card.video.startAt ?? 0, - endAt: card.video.endAt ?? videoDuration, - action: - nextStepBlock != null - ? { - create: { - blockId: nextStepBlock.stepBlockId - } - } - : undefined - } - }) - } else { - // if not video, create other card content - if (card.heading != null) { - await tx.block.create({ - data: { - journeyId, - typename: 'TypographyBlock', - parentBlockId: cardBlockId, - parentOrder: parentOrder++, - content: card.heading, - variant: 'h3' - } - }) - } + const entry = stepBlocks.find((s) => s.simpleCardId === card.id) + if (!entry) continue + const { stepBlockId, cardBlockId } = entry + + // Content blocks + for (const [blockIndex, block] of card.content.entries()) { + switch (block.type) { + case 'heading': + await tx.block.create({ + data: { + journeyId, + typename: 'TypographyBlock', + parentBlockId: cardBlockId, + parentOrder: blockIndex, + content: block.text, + variant: block.variant ?? 'h3' + } + }) + break - if (card.text != null) { - await tx.block.create({ - data: { - journeyId, - typename: 'TypographyBlock', - parentBlockId: cardBlockId, - parentOrder: parentOrder++, - content: card.text, - variant: 'body1' - } - }) - } + case 'text': + await tx.block.create({ + data: { + journeyId, + typename: 'TypographyBlock', + parentBlockId: cardBlockId, + parentOrder: blockIndex, + content: block.text, + variant: block.variant ?? 'body1' + } + }) + break + + case 'button': + await tx.block.create({ + data: { + journeyId, + typename: 'ButtonBlock', + parentBlockId: cardBlockId, + parentOrder: blockIndex, + label: block.text, + action: mapAction(block.action, stepBlocks) + } + }) + break - if (card.image != null) { - const processedImg = processedCardImages.get(card.id) - if (processedImg) { + case 'image': { + const key = `${card.id}:${block.src}` + const processed = processedImages.get(key) await tx.block.create({ data: { journeyId, typename: 'ImageBlock', parentBlockId: cardBlockId, - parentOrder: parentOrder++, - src: processedImg.src, - alt: processedImg.alt, - width: processedImg.width, - height: processedImg.height, - blurhash: processedImg.blurhash + parentOrder: blockIndex, + src: processed?.src ?? block.src, + alt: block.alt, + width: processed?.width ?? block.width ?? 1, + height: processed?.height ?? block.height ?? 1, + blurhash: processed?.blurhash ?? block.blurhash ?? '' } }) + break } - } - if (card.poll != null && card.poll.length > 0) { - const radioQuestion = await tx.block.create({ - data: { - journeyId, - typename: 'RadioQuestionBlock', - parentBlockId: cardBlockId, - parentOrder: parentOrder++ - } - }) - for (const [j, option] of card.poll.entries()) { - const nextStepBlock = - option.nextCard != null - ? stepBlocks.find((s) => s.simpleCardId === option.nextCard) - : undefined + case 'video': { + const videoId = extractYouTubeVideoId(block.url) + if (!videoId) throw new Error('Invalid YouTube video URL') + const duration = + videoMetadata.get(`${card.id}:${block.url}`) ?? 0 await tx.block.create({ data: { journeyId, - typename: 'RadioOptionBlock', - parentBlockId: radioQuestion.id, - parentOrder: j, - label: option.text, - action: - nextStepBlock != null - ? { - create: { - blockId: nextStepBlock.stepBlockId - } - } - : option.url - ? { create: { url: option.url } } - : undefined + typename: 'VideoBlock', + parentBlockId: cardBlockId, + parentOrder: blockIndex, + videoId, + source: 'youTube', + autoplay: true, + startAt: block.startAt ?? 0, + endAt: block.endAt ?? duration } }) + break } - } - if (card.button != null) { - const nextStepBlock = - card.button.nextCard != null - ? stepBlocks.find((s) => s.simpleCardId === card.button?.nextCard) - : undefined - await tx.block.create({ - data: { - journeyId, - typename: 'ButtonBlock', - parentBlockId: cardBlockId, - parentOrder: parentOrder++, - label: card.button.text, - action: - nextStepBlock != null - ? { - create: { - blockId: nextStepBlock.stepBlockId - } - } - : card.button.url - ? { create: { url: card.button.url } } - : undefined + case 'poll': { + const radioQuestion = await tx.block.create({ + data: { + journeyId, + typename: 'RadioQuestionBlock', + parentBlockId: cardBlockId, + parentOrder: blockIndex, + ...(block.gridView === true ? { gridView: true } : {}) + } + }) + for (const [j, option] of block.options.entries()) { + await tx.block.create({ + data: { + journeyId, + typename: 'RadioOptionBlock', + parentBlockId: radioQuestion.id, + parentOrder: j, + label: option.text, + ...(option.action != null + ? { action: mapAction(option.action, stepBlocks) } + : {}) + } + }) } - }) - } + break + } - if (card.backgroundImage != null) { - const processedBg = processedBackgroundImages.get(card.id) - if (processedBg) { - const bgImage = await tx.block.create({ + case 'multiselect': { + const multiselectBlock = await tx.block.create({ data: { journeyId, - typename: 'ImageBlock', - src: processedBg.src, - alt: processedBg.alt, + typename: 'MultiselectBlock', parentBlockId: cardBlockId, - width: processedBg.width, - height: processedBg.height, - blurhash: processedBg.blurhash + parentOrder: blockIndex, + ...(block.min != null ? { min: block.min } : {}), + ...(block.max != null ? { max: block.max } : {}) } }) - await tx.block.update({ - where: { id: cardBlockId }, - data: { coverBlockId: bgImage.id } - }) + for (const [j, optionText] of block.options.entries()) { + await tx.block.create({ + data: { + journeyId, + typename: 'MultiselectOptionBlock', + parentBlockId: multiselectBlock.id, + parentOrder: j, + label: optionText + } + }) + } + break } - } - if (card.defaultNextCard != null) { - const nextStepBlock = - card.defaultNextCard != null - ? stepBlocks.find((s) => s.simpleCardId === card.defaultNextCard) - : undefined - if (nextStepBlock != null) { - await tx.block.update({ - where: { id: stepBlockId }, - data: { nextBlockId: nextStepBlock.stepBlockId } + case 'textInput': + await tx.block.create({ + data: { + journeyId, + typename: 'TextResponseBlock', + parentBlockId: cardBlockId, + parentOrder: blockIndex, + label: block.label, + type: block.inputType ?? 'freeForm', + ...(block.placeholder + ? { placeholder: block.placeholder } + : {}), + ...(block.hint ? { hint: block.hint } : {}), + ...(block.required === true ? { required: true } : {}) + } }) - } + break + + case 'spacer': + await tx.block.create({ + data: { + journeyId, + typename: 'SpacerBlock', + parentBlockId: cardBlockId, + parentOrder: blockIndex, + spacing: block.spacing + } + }) + break + } + } + + // Background image (cover block) + if (card.backgroundImage) { + const processed = processedBgImages.get(card.id) + if (processed) { + const bgImageBlock = await tx.block.create({ + data: { + journeyId, + typename: 'ImageBlock', + parentBlockId: cardBlockId, + src: processed.src, + alt: processed.alt, + width: processed.width, + height: processed.height, + blurhash: processed.blurhash + } + }) + await tx.block.update({ + where: { id: cardBlockId }, + data: { coverBlockId: bgImageBlock.id } + }) + } + } + + // Background video (cover block) + if (card.backgroundVideo) { + const videoId = extractYouTubeVideoId(card.backgroundVideo.url) + if (videoId) { + const duration = videoMetadata.get(`bg:${card.id}`) ?? 0 + const bgVideoBlock = await tx.block.create({ + data: { + journeyId, + typename: 'VideoBlock', + parentBlockId: cardBlockId, + videoId, + source: 'youTube', + autoplay: true, + startAt: card.backgroundVideo.startAt ?? 0, + endAt: card.backgroundVideo.endAt ?? duration + } + }) + await tx.block.update({ + where: { id: cardBlockId }, + data: { coverBlockId: bgVideoBlock.id } + }) + } + } + + // Default next card (StepBlock.nextBlockId) + if (card.defaultNextCard) { + const nextEntry = stepBlocks.find( + (s) => s.simpleCardId === card.defaultNextCard + ) + if (nextEntry) { + await tx.block.update({ + where: { id: stepBlockId }, + data: { nextBlockId: nextEntry.stepBlockId } + }) } } } diff --git a/apis/api-journeys-modern/src/schema/journeyAiChat/executeOperation.ts b/apis/api-journeys-modern/src/schema/journeyAiChat/executeOperation.ts new file mode 100644 index 00000000000..278c4a44724 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiChat/executeOperation.ts @@ -0,0 +1,775 @@ +import { TextResponseType } from '@core/prisma/journeys/client' +import { prisma } from '@core/prisma/journeys/client' +import type { PlanOperation } from '@core/shared/ai/agentJourneyTypes' +import type { JourneySimpleAction } from '@core/shared/ai/journeySimpleTypes' + +import { processImage, updateSimpleJourney } from '../journey/simple/updateSimpleJourney' + +/** Sanitize error messages — strip Prisma internals, connection strings, stack traces */ +export function sanitizeErrorMessage(message: string): string { + // Remove Prisma-specific prefixes + const cleaned = message + .replace(/Invalid `prisma\.\w+\.\w+\([^)]*\)` invocation[\s\S]*$/, '') + .replace(/\n\s+at\s+.*$/gm, '') // stack traces + .replace(/prisma:\w+/g, '[internal]') // connection strings + .replace(/`\w+`\.`\w+`/g, '[table]') // table references + .trim() + return cleaned || 'An unexpected error occurred.' +} + +/** Validate that a block exists and belongs to the journey */ +async function validateBlock( + blockId: string, + journeyId: string +): Promise { + const block = await prisma.block.findFirst({ + where: { id: blockId, journeyId, deletedAt: null } + }) + if (!block) { + throw new Error(`Block not found or does not belong to this journey.`) + } +} + +/** + * Map from simpleId → real stepBlockId, populated by createCard during plan execution. + * This allows later operations in the same plan to reference cards created earlier. + */ +const cardIdMap = new Map() + +/** Resolve a cardId to a valid step block ID — handles real IDs, simpleIds, and plan-created cards */ +async function resolveCardId( + cardId: string, + journeyId: string +): Promise { + // Check the plan-local map first (cards created earlier in this plan) + const mapped = cardIdMap.get(cardId) + if (mapped != null) return mapped + + // Check if it's a real block ID + const block = await prisma.block.findFirst({ + where: { id: cardId, journeyId, typename: 'StepBlock', deletedAt: null }, + select: { id: true } + }) + if (block != null) return block.id + + return null +} + +/** Map a JourneySimpleAction to Prisma action create/upsert input */ +async function mapActionToPrisma( + action: JourneySimpleAction, + journeyId: string +): Promise | undefined> { + switch (action.kind) { + case 'navigate': { + const resolvedId = await resolveCardId(action.cardId, journeyId) + if (resolvedId == null) return undefined + return { upsert: { create: { blockId: resolvedId }, update: { blockId: resolvedId } } } + } + case 'url': + return { upsert: { create: { url: action.url }, update: { url: action.url } } } + case 'email': + return { upsert: { create: { email: action.email }, update: { email: action.email } } } + case 'chat': + return { upsert: { create: { chatUrl: action.chatUrl }, update: { chatUrl: action.chatUrl } } } + case 'phone': + return { + upsert: { + create: { + phone: action.phone, + ...(action.countryCode ? { countryCode: action.countryCode } : {}), + ...(action.contactAction + ? { contactAction: action.contactAction } + : {}) + }, + update: { + phone: action.phone, + ...(action.countryCode ? { countryCode: action.countryCode } : {}), + ...(action.contactAction + ? { contactAction: action.contactAction } + : {}) + } + } + } + } +} + +// --- Surgical tool implementations --- + +async function createCard( + journeyId: string, + args: { card: unknown; insertAfterCard?: string } +): Promise { + const card = args.card as { + id?: string + backgroundColor?: string + backgroundImage?: { src: string; alt: string } + content?: Array> + defaultNextCard?: string + } + + // Get current step blocks to determine order + const steps = await prisma.block.findMany({ + where: { journeyId, typename: 'StepBlock', deletedAt: null }, + orderBy: { parentOrder: 'asc' } + }) + + let insertIndex = steps.length // default: append at end + + if (args.insertAfterCard) { + const afterIdx = steps.findIndex((s) => s.id === args.insertAfterCard) + if (afterIdx >= 0) insertIndex = afterIdx + 1 + } + + // Shift subsequent steps + for (let i = insertIndex; i < steps.length; i++) { + await prisma.block.update({ + where: { id: steps[i].id }, + data: { parentOrder: i + 1 } + }) + } + + // Create StepBlock + const stepBlock = await prisma.block.create({ + data: { + journeyId, + typename: 'StepBlock', + parentOrder: insertIndex, + x: insertIndex * 300, + y: 0, + nextBlockId: card.defaultNextCard + ? (await resolveCardId(card.defaultNextCard, journeyId) ?? null) + : null + } + }) + + // Register simpleId → real ID so later operations can reference this card + if (card.id != null) { + cardIdMap.set(card.id, stepBlock.id) + } + + // Create CardBlock + const cardBlock = await prisma.block.create({ + data: { + journeyId, + typename: 'CardBlock', + parentBlockId: stepBlock.id, + parentOrder: 0, + backgroundColor: card.backgroundColor ?? null + } + }) + + // Create content blocks from card spec + if (card.content != null) { + for (const [blockIndex, block] of card.content.entries()) { + await addBlock(journeyId, { + cardBlockId: cardBlock.id, + block, + position: blockIndex + }) + } + } + + // Background image + if (card.backgroundImage != null) { + const processed = await processImage({ + src: card.backgroundImage.src, + alt: card.backgroundImage.alt + }) + const imageBlock = await prisma.block.create({ + data: { + journeyId, + typename: 'ImageBlock', + parentBlockId: cardBlock.id, + parentOrder: null, + src: processed.src, + alt: processed.alt, + width: processed.width, + height: processed.height, + blurhash: processed.blurhash + } + }) + await prisma.block.update({ + where: { id: cardBlock.id }, + data: { coverBlockId: imageBlock.id } + }) + } + + // Rewire navigation if inserting in the middle + if (args.insertAfterCard && insertIndex > 0 && insertIndex < steps.length) { + const prevStep = steps[insertIndex - 1] + const nextStep = steps[insertIndex] // the one that shifted + + await prisma.block.update({ + where: { id: prevStep.id }, + data: { nextBlockId: stepBlock.id } + }) + + await prisma.block.update({ + where: { id: stepBlock.id }, + data: { nextBlockId: nextStep.id } + }) + } +} + +async function deleteCard( + journeyId: string, + args: { cardId: string; redirectTo?: string } +): Promise { + await validateBlock(args.cardId, journeyId) + + // Determine redirect target: explicit > nextBlockId > next card by order + const deletedStep = await prisma.block.findFirst({ + where: { id: args.cardId, journeyId, deletedAt: null }, + select: { nextBlockId: true, parentOrder: true } + }) + + let redirectTo: string | null = args.redirectTo ?? deletedStep?.nextBlockId ?? null + + // If no nextBlockId, find the next card by parentOrder + if (redirectTo == null && deletedStep?.parentOrder != null) { + const nextStep = await prisma.block.findFirst({ + where: { + journeyId, + typename: 'StepBlock', + deletedAt: null, + id: { not: args.cardId }, + parentOrder: { gt: deletedStep.parentOrder } + }, + orderBy: { parentOrder: 'asc' }, + select: { id: true } + }) + redirectTo = nextStep?.id ?? null + } + + // If still no target, try the previous card + if (redirectTo == null && deletedStep?.parentOrder != null) { + const prevStep = await prisma.block.findFirst({ + where: { + journeyId, + typename: 'StepBlock', + deletedAt: null, + id: { not: args.cardId }, + parentOrder: { lt: deletedStep.parentOrder } + }, + orderBy: { parentOrder: 'desc' }, + select: { id: true } + }) + redirectTo = prevStep?.id ?? null + } + + const now = new Date().toISOString() + + // Find the card block + const cardBlock = await prisma.block.findFirst({ + where: { + journeyId, + parentBlockId: args.cardId, + typename: 'CardBlock', + deletedAt: null + } + }) + + // Soft-delete step + card + all children + await prisma.block.updateMany({ + where: { + journeyId, + deletedAt: null, + OR: [ + { id: args.cardId }, + { parentBlockId: args.cardId }, + ...(cardBlock + ? [{ parentBlockId: cardBlock.id }] + : []) + ] + }, + data: { deletedAt: now } + }) + + // Rewire navigation — always rewire to prevent broken links + if (redirectTo != null) { + // Rewire actions that navigate to the deleted card + await prisma.action.updateMany({ + where: { blockId: args.cardId }, + data: { blockId: redirectTo } + }) + + // Rewire steps that had nextBlockId pointing to deleted step + await prisma.block.updateMany({ + where: { journeyId, nextBlockId: args.cardId, deletedAt: null }, + data: { nextBlockId: redirectTo } + }) + } else { + // No redirect target — just clear the dangling references + await prisma.action.updateMany({ + where: { blockId: args.cardId }, + data: { blockId: null } + }) + + await prisma.block.updateMany({ + where: { journeyId, nextBlockId: args.cardId, deletedAt: null }, + data: { nextBlockId: null } + }) + } +} + +async function updateCard( + journeyId: string, + args: { + blockId: string + backgroundColor?: string + backgroundImage?: { src: string; alt: string } | null + defaultNextCard?: string + x?: number + y?: number + } +): Promise { + await validateBlock(args.blockId, journeyId) + + // Update StepBlock (position, nextBlockId) + const stepUpdate: Record = {} + if (args.x !== undefined) stepUpdate.x = args.x + if (args.y !== undefined) stepUpdate.y = args.y + if (args.defaultNextCard !== undefined) { + if (args.defaultNextCard === null || args.defaultNextCard === '') { + stepUpdate.nextBlockId = null + } else { + // Validate the target block exists before setting FK + const targetBlock = await prisma.block.findFirst({ + where: { id: args.defaultNextCard, journeyId, deletedAt: null } + }) + if (targetBlock != null) { + stepUpdate.nextBlockId = args.defaultNextCard + } + } + } + + if (Object.keys(stepUpdate).length > 0) { + await prisma.block.update({ + where: { id: args.blockId }, + data: stepUpdate + }) + } + + const cardBlock = await prisma.block.findFirst({ + where: { + journeyId, + parentBlockId: args.blockId, + typename: 'CardBlock', + deletedAt: null + } + }) + if (cardBlock == null) return + + // Update CardBlock (backgroundColor) + if (args.backgroundColor !== undefined) { + await prisma.block.update({ + where: { id: cardBlock.id }, + data: { backgroundColor: args.backgroundColor } + }) + } + + // Update, create, or remove background image (cover block) + if (args.backgroundImage !== undefined) { + if (args.backgroundImage === null) { + // Remove background image + if (cardBlock.coverBlockId != null) { + await prisma.block.updateMany({ + where: { id: cardBlock.coverBlockId, journeyId, deletedAt: null }, + data: { deletedAt: new Date().toISOString() } + }) + await prisma.block.update({ + where: { id: cardBlock.id }, + data: { coverBlockId: null } + }) + } + } else { + // Set or update background image + const processed = await processImage({ + src: args.backgroundImage.src, + alt: args.backgroundImage.alt + }) + + const existingCover = + cardBlock.coverBlockId != null + ? await prisma.block.findFirst({ + where: { + id: cardBlock.coverBlockId, + journeyId, + typename: 'ImageBlock', + deletedAt: null + } + }) + : null + + if (existingCover != null) { + await prisma.block.update({ + where: { id: existingCover.id }, + data: { + src: processed.src, + alt: processed.alt, + width: processed.width, + height: processed.height, + blurhash: processed.blurhash + } + }) + } else { + const imageBlock = await prisma.block.create({ + data: { + journeyId, + typename: 'ImageBlock', + parentBlockId: cardBlock.id, + parentOrder: null, + src: processed.src, + alt: processed.alt, + width: processed.width, + height: processed.height, + blurhash: processed.blurhash + } + }) + await prisma.block.update({ + where: { id: cardBlock.id }, + data: { coverBlockId: imageBlock.id } + }) + } + } + } +} + +async function addBlock( + journeyId: string, + args: { cardBlockId: string; block: unknown; position?: number } +): Promise { + await validateBlock(args.cardBlockId, journeyId) + + const block = args.block as Record + const type = block.type as string + + // Get current children to determine parentOrder + const siblings = await prisma.block.findMany({ + where: { + journeyId, + parentBlockId: args.cardBlockId, + deletedAt: null + }, + orderBy: { parentOrder: 'asc' } + }) + + const position = args.position ?? siblings.length + + // Shift subsequent siblings + for (let i = position; i < siblings.length; i++) { + await prisma.block.update({ + where: { id: siblings[i].id }, + data: { parentOrder: i + 1 } + }) + } + + // Create the block based on type + switch (type) { + case 'heading': + case 'text': + await prisma.block.create({ + data: { + journeyId, + typename: 'TypographyBlock', + parentBlockId: args.cardBlockId, + parentOrder: position, + content: block.text as string, + variant: (block.variant as string) ?? (type === 'heading' ? 'h3' : 'body1') + } + }) + break + + case 'button': { + const action = block.action as JourneySimpleAction | undefined + await prisma.block.create({ + data: { + journeyId, + typename: 'ButtonBlock', + parentBlockId: args.cardBlockId, + parentOrder: position, + label: block.text as string, + ...(action ? { action: await mapActionToPrisma(action, journeyId) } : {}) + } + }) + break + } + + case 'image': + await prisma.block.create({ + data: { + journeyId, + typename: 'ImageBlock', + parentBlockId: args.cardBlockId, + parentOrder: position, + src: block.src as string, + alt: block.alt as string, + width: (block.width as number) ?? 1, + height: (block.height as number) ?? 1, + blurhash: (block.blurhash as string) ?? '' + } + }) + break + + case 'textInput': + await prisma.block.create({ + data: { + journeyId, + typename: 'TextResponseBlock', + parentBlockId: args.cardBlockId, + parentOrder: position, + label: block.label as string, + type: ((block.inputType as string) ?? 'freeForm') as TextResponseType, + ...(block.placeholder ? { placeholder: block.placeholder as string } : {}), + ...(block.hint ? { hint: block.hint as string } : {}), + ...(block.required === true ? { required: true } : {}) + } + }) + break + + case 'spacer': + await prisma.block.create({ + data: { + journeyId, + typename: 'SpacerBlock', + parentBlockId: args.cardBlockId, + parentOrder: position, + spacing: block.spacing as number + } + }) + break + + case 'poll': { + const pollBlock = await prisma.block.create({ + data: { + journeyId, + typename: 'RadioQuestionBlock', + parentBlockId: args.cardBlockId, + parentOrder: position + } + }) + const options = (block.options as Array<{ text: string; action: JourneySimpleAction }>) ?? [] + for (const [j, option] of options.entries()) { + await prisma.block.create({ + data: { + journeyId, + typename: 'RadioOptionBlock', + parentBlockId: pollBlock.id, + parentOrder: j, + label: option.text, + ...(option.action ? { action: await mapActionToPrisma(option.action, journeyId) } : {}) + } + }) + } + break + } + + case 'multiselect': { + const msBlock = await prisma.block.create({ + data: { + journeyId, + typename: 'MultiselectBlock', + parentBlockId: args.cardBlockId, + parentOrder: position + } + }) + const opts = (block.options as string[]) ?? [] + for (const [j, optText] of opts.entries()) { + await prisma.block.create({ + data: { + journeyId, + typename: 'MultiselectOptionBlock', + parentBlockId: msBlock.id, + parentOrder: j, + label: optText + } + }) + } + break + } + } +} + +async function updateBlock( + journeyId: string, + args: { blockId: string; updates: Record } +): Promise { + await validateBlock(args.blockId, journeyId) + + const block = await prisma.block.findFirst({ + where: { id: args.blockId, journeyId, deletedAt: null } + }) + if (!block) throw new Error('Block not found.') + + const data: Record = {} + + switch (block.typename) { + case 'TypographyBlock': + if (args.updates.text !== undefined) data.content = args.updates.text + if (args.updates.variant !== undefined) data.variant = args.updates.variant + break + case 'ButtonBlock': + if (args.updates.text !== undefined) data.label = args.updates.text + if (args.updates.action !== undefined) { + const actionData = await mapActionToPrisma( + args.updates.action as JourneySimpleAction, + journeyId + ) + if (actionData != null) data.action = actionData + } + break + case 'RadioOptionBlock': + if (args.updates.text !== undefined) data.label = args.updates.text + if (args.updates.action !== undefined) { + const actionData = await mapActionToPrisma( + args.updates.action as JourneySimpleAction, + journeyId + ) + if (actionData != null) data.action = actionData + } + break + case 'ImageBlock': + if (args.updates.src !== undefined) data.src = args.updates.src + if (args.updates.alt !== undefined) data.alt = args.updates.alt + break + case 'TextResponseBlock': + if (args.updates.label !== undefined) data.label = args.updates.label + if (args.updates.placeholder !== undefined) + data.placeholder = args.updates.placeholder + if (args.updates.hint !== undefined) data.hint = args.updates.hint + if (args.updates.inputType !== undefined) + data.type = args.updates.inputType + break + case 'SpacerBlock': + if (args.updates.spacing !== undefined) data.spacing = args.updates.spacing + break + } + + if (Object.keys(data).length > 0) { + await prisma.block.update({ + where: { id: args.blockId }, + data + }) + } +} + +async function deleteBlock( + journeyId: string, + args: { blockId: string } +): Promise { + await validateBlock(args.blockId, journeyId) + + const block = await prisma.block.findFirst({ + where: { id: args.blockId, journeyId, deletedAt: null } + }) + if (!block) throw new Error('Block not found.') + + const now = new Date().toISOString() + + // Soft-delete the block and its children (for compound blocks like poll) + await prisma.block.updateMany({ + where: { + journeyId, + deletedAt: null, + OR: [{ id: args.blockId }, { parentBlockId: args.blockId }] + }, + data: { deletedAt: now } + }) + + // Shift siblings + if (block.parentBlockId && block.parentOrder != null) { + await prisma.block.updateMany({ + where: { + journeyId, + parentBlockId: block.parentBlockId, + parentOrder: { gt: block.parentOrder }, + deletedAt: null + }, + data: { parentOrder: { decrement: 1 } } + }) + } +} + +async function reorderCards( + journeyId: string, + args: { blockIds: string[] } +): Promise { + for (const [i, blockId] of args.blockIds.entries()) { + await prisma.block.update({ + where: { id: blockId }, + data: { parentOrder: i, x: i * 300 } + }) + } +} + +async function updateJourneySettings( + journeyId: string, + args: { title?: string; description?: string } +): Promise { + const data: Record = {} + if (args.title !== undefined) data.title = args.title + if (args.description !== undefined) data.description = args.description + + if (Object.keys(data).length > 0) { + await prisma.journey.update({ + where: { id: journeyId }, + data + }) + } +} + +// --- Plan lifecycle --- + +/** Seed the card ID map with existing journey card simpleId→blockId mappings */ +export function seedCardIdMap( + mappings: Array<{ simpleId: string; blockId: string }> +): void { + cardIdMap.clear() + for (const { simpleId, blockId } of mappings) { + cardIdMap.set(simpleId, blockId) + } +} + +/** Clear the card ID map between plan executions */ +export function clearCardIdMap(): void { + cardIdMap.clear() +} + +// --- Main executor --- + +export async function executeOperation( + op: PlanOperation, + journeyId: string +): Promise { + switch (op.tool) { + case 'generate_journey': { + const simple = op.args.simple + if (simple?.cards == null) { + throw new Error( + 'generate_journey requires args.simple with title, description, and cards' + ) + } + return updateSimpleJourney(journeyId, simple) + } + case 'create_card': + return createCard(journeyId, op.args as any) + case 'delete_card': + return deleteCard(journeyId, op.args) + case 'update_card': + return updateCard(journeyId, op.args) + case 'add_block': + return addBlock(journeyId, op.args as any) + case 'update_block': + return updateBlock(journeyId, op.args) + case 'delete_block': + return deleteBlock(journeyId, op.args) + case 'reorder_cards': + return reorderCards(journeyId, op.args) + case 'update_journey_settings': + return updateJourneySettings(journeyId, op.args) + case 'translate': + // Delegate to existing translate subscription (Phase 3) + throw new Error('Translation via AI chat is not yet implemented.') + } +} diff --git a/apis/api-journeys-modern/src/schema/journeyAiChat/getAgentJourney.ts b/apis/api-journeys-modern/src/schema/journeyAiChat/getAgentJourney.ts new file mode 100644 index 00000000000..d0a7d8946f2 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiChat/getAgentJourney.ts @@ -0,0 +1,450 @@ +import type { Prisma } from '@core/prisma/journeys/client' +import { prisma } from '@core/prisma/journeys/client' +import type { + AgentJourney, + AgentJourneyCard, + NavigationMapEntry +} from '@core/shared/ai/agentJourneyTypes' +import type { JourneySimpleBlock } from '@core/shared/ai/journeySimpleTypes' + +type Block = Prisma.JourneyGetPayload<{ + include: { blocks: { include: { action: true } } } +}>['blocks'][number] + +type StepBlock = Block & { typename: 'StepBlock' } + +/** Sort blocks by parentOrder (null -> Infinity) */ +function sortByParentOrder(blocks: Block[]): Block[] { + return [...blocks].sort( + (a, b) => (a.parentOrder ?? Infinity) - (b.parentOrder ?? Infinity) + ) +} + +/** Generate content-derived card IDs from heading/text content */ +function generateCardIds( + cards: Array<{ heading?: string; firstText?: string }> +): string[] { + const usedIds = new Set() + return cards.map((card) => { + const label = card.heading ?? card.firstText ?? 'untitled' + const slug = + label + .toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .trim() + .split(/\s+/) + .slice(0, 4) + .join('-') || 'untitled' + let id = `card-${slug}` + let counter = 2 + while (usedIds.has(id)) { + id = `card-${slug}-${counter++}` + } + usedIds.add(id) + return id + }) +} + +/** Resolve a navigate action blockId to a card simpleId */ +function resolveNavigateCardId( + targetBlockId: string | null, + stepBlocks: StepBlock[], + cardIds: string[] +): string | undefined { + if (targetBlockId == null) return undefined + const idx = stepBlocks.findIndex((s) => s.id === targetBlockId) + if (idx < 0) return undefined + return cardIds[idx] +} + +/** Map a block's children to JourneySimpleBlock items with blockId */ +function mapContentBlocks( + childBlocks: Block[], + cardBlock: Block, + allBlocks: Block[], + stepBlocks: StepBlock[], + cardIds: string[] +): Array { + const content: Array = [] + + for (const block of childBlocks) { + if (block.id === cardBlock.coverBlockId) continue + + switch (block.typename) { + case 'TypographyBlock': { + const variant = block.variant ?? 'body1' + const isHeading = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes( + variant + ) + if (isHeading) { + content.push({ + blockId: block.id, + type: 'heading', + text: block.content ?? '', + variant: variant as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + }) + } else { + content.push({ + blockId: block.id, + type: 'text', + text: block.content ?? '', + variant: variant as + | 'body1' + | 'body2' + | 'subtitle1' + | 'subtitle2' + | 'caption' + | 'overline' + }) + } + break + } + + case 'ButtonBlock': { + const action = mapAction(block.action, stepBlocks, cardIds) + if (action) { + content.push({ + blockId: block.id, + type: 'button', + text: block.label ?? '', + action + }) + } + break + } + + case 'ImageBlock': { + content.push({ + blockId: block.id, + type: 'image', + src: block.src ?? '', + alt: block.alt ?? '', + ...(block.width != null ? { width: block.width } : {}), + ...(block.height != null ? { height: block.height } : {}), + ...(block.blurhash ? { blurhash: block.blurhash } : {}) + }) + break + } + + case 'VideoBlock': { + if (block.source === 'youTube' && block.videoId) { + content.push({ + blockId: block.id, + type: 'video', + url: `https://youtube.com/watch?v=${block.videoId}`, + ...(block.startAt != null ? { startAt: block.startAt } : {}), + ...(block.endAt != null ? { endAt: block.endAt } : {}) + }) + } + break + } + + case 'RadioQuestionBlock': { + const optionBlocks = sortByParentOrder( + allBlocks.filter( + (b) => + b.typename === 'RadioOptionBlock' && + b.parentBlockId === block.id + ) + ) + const options = optionBlocks.map((opt) => { + const action = mapAction(opt.action, stepBlocks, cardIds) + return { + text: opt.label ?? '', + blockId: opt.id, + ...(action != null ? { action } : {}) + } + }) + if (options.length >= 2) { + content.push({ + blockId: block.id, + type: 'poll', + gridView: block.gridView === true, + options + } as JourneySimpleBlock & { blockId: string }) + } + break + } + + case 'MultiselectBlock': { + const optionBlocks = sortByParentOrder( + allBlocks.filter( + (b) => + b.typename === 'MultiselectOptionBlock' && + b.parentBlockId === block.id + ) + ) + const options = optionBlocks.map((opt) => opt.label ?? '') + if (options.length >= 2) { + content.push({ + blockId: block.id, + type: 'multiselect', + ...(block.min != null ? { min: block.min } : {}), + ...(block.max != null ? { max: block.max } : {}), + options + }) + } + break + } + + case 'TextResponseBlock': { + const inputTypeMap: Record = { + freeForm: 'freeForm', + name: 'name', + email: 'email', + phone: 'phone' + } + content.push({ + blockId: block.id, + type: 'textInput', + label: block.label ?? '', + ...(block.type && inputTypeMap[block.type] + ? { + inputType: inputTypeMap[block.type] as + | 'freeForm' + | 'name' + | 'email' + | 'phone' + } + : {}), + ...(block.placeholder ? { placeholder: block.placeholder } : {}), + ...(block.hint ? { hint: block.hint } : {}), + ...(block.required === true ? { required: true } : {}) + } as JourneySimpleBlock & { blockId: string }) + break + } + + case 'SpacerBlock': { + if (block.spacing != null && block.spacing > 0) { + content.push({ + blockId: block.id, + type: 'spacer', + spacing: block.spacing + }) + } + break + } + } + } + + return content +} + +/** Map a Prisma Action to a JourneySimpleAction */ +function mapAction( + action: Block['action'], + stepBlocks: StepBlock[], + cardIds: string[] +): + | { + kind: 'navigate' + cardId: string + } + | { kind: 'url'; url: string } + | { kind: 'email'; email: string } + | { kind: 'chat'; chatUrl: string } + | { + kind: 'phone' + phone: string + countryCode?: string + contactAction?: 'call' | 'text' + } + | undefined { + if (!action) return undefined + if (action.blockId) { + const idx = stepBlocks.findIndex((s) => s.id === action.blockId) + if (idx >= 0) return { kind: 'navigate', cardId: cardIds[idx] } + } + if (action.url) return { kind: 'url', url: action.url } + if (action.email) return { kind: 'email', email: action.email } + if ((action as Record).chatUrl) + return { + kind: 'chat', + chatUrl: (action as Record).chatUrl as string + } + if (action.phone) { + return { + kind: 'phone', + phone: action.phone, + ...(action.countryCode ? { countryCode: action.countryCode } : {}), + ...(action.contactAction === 'call' || action.contactAction === 'text' + ? { contactAction: action.contactAction } + : {}) + } + } + return undefined +} + +/** Read a journey from Prisma and map it to AgentJourney for the AI system prompt */ +export async function getAgentJourney( + journeyId: string, + languageName?: string | null +): Promise { + const journey = await prisma.journey.findUniqueOrThrow({ + where: { id: journeyId }, + include: { + blocks: { + where: { deletedAt: null }, + include: { action: true } + } + } + }) + + const stepBlocks = journey.blocks.filter( + (block): block is StepBlock => block.typename === 'StepBlock' + ) + + const cardMeta = stepBlocks.map((stepBlock) => { + const cardBlock = journey.blocks.find( + (b) => b.parentBlockId === stepBlock.id + ) + if (!cardBlock) return { heading: undefined, firstText: undefined } + const children = sortByParentOrder( + journey.blocks.filter((b) => b.parentBlockId === cardBlock.id) + ) + const headingBlock = children.find( + (b) => + b.typename === 'TypographyBlock' && + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(b.variant ?? '') + ) + const textBlock = children.find( + (b) => + b.typename === 'TypographyBlock' && + !['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(b.variant ?? '') + ) + return { + heading: headingBlock?.content ?? undefined, + firstText: textBlock?.content ?? undefined + } + }) + + const cardIds = generateCardIds(cardMeta) + + const navigationMap: NavigationMapEntry[] = [] + + const cards: AgentJourneyCard[] = stepBlocks.map((stepBlock, index) => { + const cardBlock = journey.blocks.find( + (b) => b.parentBlockId === stepBlock.id + ) + if (!cardBlock) throw new Error('Card block not found') + + const childBlocks = sortByParentOrder( + journey.blocks.filter((b) => b.parentBlockId === cardBlock.id) + ) + + const content = mapContentBlocks( + childBlocks, + cardBlock, + journey.blocks, + stepBlocks, + cardIds + ) + + const card: AgentJourneyCard = { + simpleId: cardIds[index], + blockId: stepBlock.id, + cardBlockId: cardBlock.id, + content, + ...(cardBlock.backgroundColor + ? { backgroundColor: cardBlock.backgroundColor } + : {}) + } + + // Heading + const headingItem = content.find((c) => c.type === 'heading') + if (headingItem && 'text' in headingItem) { + card.heading = headingItem.text + } + + // Background image from cover block + if (cardBlock.coverBlockId) { + const coverBlock = journey.blocks.find( + (b) => b.id === cardBlock.coverBlockId + ) + if (coverBlock?.typename === 'ImageBlock') { + card.backgroundImage = { + src: coverBlock.src ?? '', + alt: coverBlock.alt ?? '' + } + } else if ( + coverBlock?.typename === 'VideoBlock' && + coverBlock.source === 'youTube' && + coverBlock.videoId + ) { + card.backgroundVideo = { + url: `https://youtube.com/watch?v=${coverBlock.videoId}` + } + } + } + + // Default next card + const defaultNextCardId = resolveNavigateCardId( + stepBlock.nextBlockId, + stepBlocks, + cardIds + ) + if (defaultNextCardId != null) { + card.defaultNextCard = defaultNextCardId + navigationMap.push({ + sourceCardId: cardIds[index], + targetCardId: defaultNextCardId, + trigger: 'default', + blockId: stepBlock.id + }) + } + + // Button navigate actions + for (const item of content) { + if (item.type === 'button' && 'action' in item) { + const action = item.action as { kind: string; cardId?: string } + if (action.kind === 'navigate' && action.cardId) { + navigationMap.push({ + sourceCardId: cardIds[index], + targetCardId: action.cardId, + trigger: 'button', + label: 'text' in item ? (item.text as string) : undefined, + blockId: item.blockId + }) + } + } + } + + // Poll (RadioOption) navigate actions + for (const block of childBlocks) { + if (block.typename !== 'RadioQuestionBlock') continue + const optionBlocks = sortByParentOrder( + journey.blocks.filter( + (b) => + b.typename === 'RadioOptionBlock' && + b.parentBlockId === block.id + ) + ) + for (const opt of optionBlocks) { + if (!opt.action?.blockId) continue + const targetCardId = resolveNavigateCardId( + opt.action.blockId, + stepBlocks, + cardIds + ) + if (targetCardId == null) continue + navigationMap.push({ + sourceCardId: cardIds[index], + targetCardId, + trigger: 'poll', + label: opt.label ?? undefined, + blockId: opt.id + }) + } + } + + return card + }) + + return { + title: journey.title, + description: journey.description ?? '', + language: languageName ?? 'English', + cards, + navigationMap + } +} diff --git a/apis/api-journeys-modern/src/schema/journeyAiChat/index.ts b/apis/api-journeys-modern/src/schema/journeyAiChat/index.ts new file mode 100644 index 00000000000..e627791fe17 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiChat/index.ts @@ -0,0 +1 @@ +import './journeyAiChat' diff --git a/apis/api-journeys-modern/src/schema/journeyAiChat/journeyAiChat.ts b/apis/api-journeys-modern/src/schema/journeyAiChat/journeyAiChat.ts new file mode 100644 index 00000000000..8ebabe653f3 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiChat/journeyAiChat.ts @@ -0,0 +1,658 @@ +import { createAnthropic } from '@ai-sdk/anthropic' +import { google } from '@ai-sdk/google' +import { stepCountIs, streamText, tool } from 'ai' +import { randomUUID } from 'crypto' +import { GraphQLError } from 'graphql' + +import { prisma } from '@core/prisma/journeys/client' +import { + planOperationArraySchema, + type PlanOperation +} from '@core/shared/ai/agentJourneyTypes' +import { hardenPrompt, preSystemPrompt } from '@core/shared/ai/prompts' + +import { env } from '../../env' +import { Action, ability, subject } from '../../lib/auth/ability' +import { builder } from '../builder' +import { getSimpleJourney, updateSimpleJourney } from '../journey/simple' + +import { clearCardIdMap, executeOperation, sanitizeErrorMessage, seedCardIdMap } from './executeOperation' +import { getAgentJourney } from './getAgentJourney' +import { + deleteSnapshot, + getSnapshot, + saveSnapshot, + updateSnapshotPlan +} from './snapshotStore' +import { systemPrompt } from './systemPrompt' +import { searchImagesTool } from './tools/searchImages' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface HistoryMessage { + role: string + content: string +} + +type JourneyAiChatMessageType = + | 'text' + | 'tool_call' + | 'tool_result' + | 'plan' + | 'plan_progress' + | 'done' + | 'warning' + | 'error' + +interface JourneyAiChatMessage { + type: JourneyAiChatMessageType + text: string | null + operations: string | null + operationId: string | null + status: string | null + turnId: string | null + journeyUpdated: boolean | null + requiresConfirmation: boolean | null + name: string | null + args: string | null + summary: string | null + cardId: string | null + error: string | null + validation: string | null +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function countBlocks(agentJourney: { cards: Array<{ content: unknown[] }> }): number { + return agentJourney.cards.reduce((sum, c) => sum + c.content.length, 0) +} + +function summarizeToolResult(result: unknown): string { + if (result == null) return '' + if (typeof result === 'string') return result.slice(0, 200) + try { + const json = JSON.stringify(result) + return json.length > 200 ? json.slice(0, 200) + '...' : json + } catch { + return String(result) + } +} + +/** Truncate history to the last 20 messages, keeping system context */ +function condenseHistory( + history: HistoryMessage[] +): Array<{ role: 'user' | 'assistant'; content: string }> { + const MAX_MESSAGES = 20 + const mapped = history + .filter((m) => m.role === 'user' || m.role === 'assistant') + .map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content + })) + + if (mapped.length <= MAX_MESSAGES) return mapped + return mapped.slice(mapped.length - MAX_MESSAGES) +} + +/** Simple post-execution validation — checks every card has at least one nav path */ +async function validateJourney( + journeyId: string +): Promise { + try { + const agentJourney = await getAgentJourney(journeyId) + const warnings: string[] = [] + + for (const card of agentJourney.cards) { + const hasVideo = card.content.some((b) => b.type === 'video') + if (hasVideo) continue + + const hasButton = card.content.some((b) => b.type === 'button') + const hasPoll = card.content.some((b) => b.type === 'poll') + const hasDefaultNext = card.defaultNextCard != null + + if (!hasButton && !hasPoll && !hasDefaultNext) { + warnings.push( + `Card "${card.heading ?? card.simpleId}" has no navigation path.` + ) + } + } + + return warnings.length > 0 ? warnings.join(' ') : null + } catch { + return null + } +} + +// --------------------------------------------------------------------------- +// Pothos: Message object type +// --------------------------------------------------------------------------- + +const JourneyAiChatMessageRef = + builder.objectRef('JourneyAiChatMessage') + +builder.objectType(JourneyAiChatMessageRef, { + fields: (t) => ({ + type: t.string({ resolve: (p) => p.type }), + text: t.string({ nullable: true, resolve: (p) => p.text }), + operations: t.string({ nullable: true, resolve: (p) => p.operations }), + operationId: t.string({ nullable: true, resolve: (p) => p.operationId }), + status: t.string({ nullable: true, resolve: (p) => p.status }), + turnId: t.string({ nullable: true, resolve: (p) => p.turnId }), + journeyUpdated: t.boolean({ + nullable: true, + resolve: (p) => p.journeyUpdated + }), + requiresConfirmation: t.boolean({ + nullable: true, + resolve: (p) => p.requiresConfirmation + }), + name: t.string({ nullable: true, resolve: (p) => p.name }), + args: t.string({ nullable: true, resolve: (p) => p.args }), + summary: t.string({ nullable: true, resolve: (p) => p.summary }), + cardId: t.string({ nullable: true, resolve: (p) => p.cardId }), + error: t.string({ nullable: true, resolve: (p) => p.error }), + validation: t.string({ nullable: true, resolve: (p) => p.validation }) + }) +}) + +// --------------------------------------------------------------------------- +// Pothos: Input types +// --------------------------------------------------------------------------- + +const HistoryMessageInput = builder.inputType('JourneyAiChatHistoryMessage', { + fields: (t) => ({ + role: t.string({ required: true }), + content: t.string({ required: true }) + }) +}) + +const PreferredTierEnum = builder.enumType('JourneyAiChatPreferredTier', { + values: ['free', 'premium'] as const +}) + +const JourneyAiChatInput = builder.inputType('JourneyAiChatInput', { + fields: (t) => ({ + journeyId: t.id({ required: true }), + message: t.string({ required: true }), + history: t.field({ + type: [HistoryMessageInput], + required: true + }), + turnId: t.string({ required: false }), + contextCardId: t.string({ required: false }), + preferredTier: t.field({ type: PreferredTierEnum, required: false }), + languageName: t.string({ required: false }) + }) +}) + +// --------------------------------------------------------------------------- +// Helper: build a message payload with all fields defaulting to null +// --------------------------------------------------------------------------- + +function msg( + overrides: Partial & { type: JourneyAiChatMessageType } +): JourneyAiChatMessage { + return { + text: null, + operations: null, + operationId: null, + status: null, + turnId: null, + journeyUpdated: null, + requiresConfirmation: null, + name: null, + args: null, + summary: null, + cardId: null, + error: null, + validation: null, + ...overrides + } +} + +// --------------------------------------------------------------------------- +// Helpers: resolve block IDs for UI highlighting +// --------------------------------------------------------------------------- + +/** Extract the most relevant block ID from an operation's args */ +function resolveTargetBlockId(op: PlanOperation): string | null { + switch (op.tool) { + case 'update_block': + case 'delete_block': + return op.args.blockId + case 'add_block': + return op.args.cardBlockId + case 'update_card': + return op.args.blockId + case 'delete_card': + return op.args.cardId + case 'create_card': + case 'generate_journey': + case 'reorder_cards': + case 'update_journey_settings': + case 'translate': + return null + } +} + +/** Walk up the block tree to find the StepBlock ancestor */ +async function resolveStepBlockId( + blockId: string, + journeyId: string +): Promise { + const maxDepth = 5 + let currentId: string | null = blockId + + for (let i = 0; i < maxDepth && currentId != null; i++) { + const block: { id: string; typename: string; parentBlockId: string | null } | null = await prisma.block.findFirst({ + where: { id: currentId, journeyId, deletedAt: null }, + select: { id: true, typename: true, parentBlockId: true } + }) + if (block == null) return null + if (block.typename === 'StepBlock') return block.id + currentId = block.parentBlockId + } + + return null +} + +// --------------------------------------------------------------------------- +// Subscription: journeyAiChatCreateSubscription +// --------------------------------------------------------------------------- + +builder.subscriptionField('journeyAiChatCreateSubscription', (t) => + t.withAuth({ isAuthenticated: true }).field({ + type: JourneyAiChatMessageRef, + nullable: false, + args: { + input: t.arg({ type: JourneyAiChatInput, required: true }) + }, + subscribe: async function* (_root, { input }, context) { + // --- Auth --- + const journey = await prisma.journey.findUnique({ + where: { id: input.journeyId }, + include: { + userJourneys: true, + team: { include: { userTeams: true } } + } + }) + + if (journey == null) { + throw new GraphQLError('journey not found') + } + + if ( + !ability(Action.Update, subject('Journey', journey), context.user) + ) { + throw new GraphQLError( + 'user does not have permission to update journey' + ) + } + + // --- Published journey warning --- + if (journey.status === 'published') { + yield msg({ + type: 'warning', + text: 'This journey is published and live. Changes will be visible to users immediately.' + }) + } + + // --- Abort controller --- + const abort = new AbortController() + // The request signal may not be present in all Yoga transports, guard it + const requestSignal = (context as unknown as { request?: { signal?: AbortSignal } }).request?.signal + if (requestSignal) { + requestSignal.addEventListener('abort', () => abort.abort()) + } + + // --- Snapshot for undo --- + const turnId = input.turnId ?? randomUUID() + const snapshot = await getSimpleJourney(input.journeyId) + await saveSnapshot(turnId, { + snapshot, + userId: context.user.id, + journeyId: input.journeyId, + plan: null + }) + + // --- Tiered model selection --- + const preferFree = input.preferredTier === 'free' + const anthropicKey = env.ANTHROPIC_API_KEY + const model = ( + anthropicKey && !preferFree + ? createAnthropic({ apiKey: anthropicKey })('claude-sonnet-4-6') + : google('gemini-2.5-flash') + ) as Parameters[0]['model'] + + // --- System prompt with journey state --- + const agentJourney = await getAgentJourney(input.journeyId, input.languageName) + + const contextCard = input.contextCardId + ? agentJourney.cards.find( + (c) => + c.simpleId === input.contextCardId || + c.blockId === input.contextCardId + ) + : null + + const contextSuffix = contextCard + ? `\n\n## SELECTED CARD CONTEXT\nThe user has selected the "${contextCard.heading ?? 'Untitled'}" card (stepBlockId: ${contextCard.blockId}, cardBlockId: ${contextCard.cardBlockId}). Their next message refers specifically to THIS card. Apply changes ONLY to this card unless the user explicitly asks for changes to other cards or the whole journey.` + : '' + + const fullSystemPrompt = `${preSystemPrompt}\n\n${systemPrompt}\n\n## Current Journey State\n${hardenPrompt(JSON.stringify(agentJourney))}\n\nSummary: ${agentJourney.cards.length} cards, ${countBlocks(agentJourney)} blocks.${contextSuffix}` + + // --- History --- + const condensedHistory = condenseHistory( + (input.history ?? []) as HistoryMessage[] + ) + + // --- Prefix user message with card context --- + const userMessage = contextCard + ? `[Selected card: "${contextCard.heading ?? 'Untitled'}" (stepBlockId: ${contextCard.blockId}, cardBlockId: ${contextCard.cardBlockId})]\n${input.message}` + : input.message + + // --- Phase 1: Plan (streamText with tools) --- + const result = streamText({ + model, + system: fullSystemPrompt, + messages: [ + ...condensedHistory, + { role: 'user' as const, content: userMessage } + ], + tools: { + search_images: searchImagesTool, + submit_plan: tool({ + description: 'Submit your execution plan as a list of operations.', + inputSchema: planOperationArraySchema, + execute: async (args: { operations: PlanOperation[] }) => args + }) + }, + stopWhen: stepCountIs(8), + abortSignal: abort.signal + }) + + // --- Stream events to client --- + let plan: PlanOperation[] | null = null + + for await (const event of result.fullStream) { + if (abort.signal.aborted) break + + switch (event.type) { + case 'text-delta': + yield msg({ type: 'text', text: event.text }) + break + + case 'tool-call': + if (event.toolName === 'submit_plan') { + plan = (event.input as { operations: PlanOperation[] }).operations + } + yield msg({ + type: 'tool_call', + name: event.toolName, + args: JSON.stringify(event.input) + }) + break + + case 'tool-result': + yield msg({ + type: 'tool_result', + name: event.toolName, + summary: summarizeToolResult(event.output) + }) + break + } + } + + // --- If AI searched images but didn't submit a plan, nudge it to continue --- + if (plan == null) { + const calledSearchImages = result.steps.some( + (step) => step.toolCalls?.some((tc) => tc.toolName === 'search_images') + ) + + if (calledSearchImages && !abort.signal.aborted) { + // Re-run with the accumulated messages to get the plan + const continueResult = streamText({ + model, + system: fullSystemPrompt, + messages: [ + ...condensedHistory, + { role: 'user' as const, content: userMessage }, + ...await result.response.then(r => r.messages) + ], + tools: { + search_images: searchImagesTool, + submit_plan: tool({ + description: 'Submit your execution plan as a list of operations.', + inputSchema: planOperationArraySchema, + execute: async (args: { operations: PlanOperation[] }) => args + }) + }, + toolChoice: { type: 'tool', toolName: 'submit_plan' }, + abortSignal: abort.signal + }) + + for await (const event of continueResult.fullStream) { + if (abort.signal.aborted) break + switch (event.type) { + case 'text-delta': + yield msg({ type: 'text', text: event.text }) + break + case 'tool-call': + if (event.toolName === 'submit_plan') { + plan = (event.input as { operations: PlanOperation[] }).operations + } + break + } + } + } + } + + // --- No plan — conversational response --- + if (plan == null) { + yield msg({ type: 'done', journeyUpdated: false, turnId }) + return + } + + // --- Server-derived confirmation --- + const requiresConfirmation = + plan.filter((o) => o.tool === 'delete_card').length > 3 + + yield msg({ + type: 'plan', + operations: JSON.stringify(plan), + turnId, + requiresConfirmation + }) + + if (requiresConfirmation) { + await updateSnapshotPlan(turnId, plan) + yield msg({ type: 'done', journeyUpdated: false, turnId }) + return + } + + // --- Phase 2: Server-driven execution --- + seedCardIdMap( + agentJourney.cards.map((c) => ({ simpleId: c.simpleId, blockId: c.blockId })) + ) + let journeyUpdated = false + + for (const op of plan) { + if (abort.signal.aborted) break + + // Resolve the step block ID for UI highlighting + const targetBlockId = resolveTargetBlockId(op) + const stepBlockId = targetBlockId != null + ? await resolveStepBlockId(targetBlockId, input.journeyId) + : null + + yield msg({ + type: 'plan_progress', + operationId: op.id, + status: 'running', + cardId: stepBlockId ?? op.cardId ?? null + }) + + try { + await executeOperation(op, input.journeyId) + yield msg({ + type: 'plan_progress', + operationId: op.id, + status: 'done', + cardId: stepBlockId ?? op.cardId ?? null, + journeyUpdated: true + }) + journeyUpdated = true + } catch (err) { + const safeMessage = sanitizeErrorMessage((err as Error).message) + console.error('Operation failed', { operationId: op.id, error: err }) + + yield msg({ + type: 'plan_progress', + operationId: op.id, + status: 'failed', + error: safeMessage + }) + yield msg({ + type: 'text', + text: `Failed: ${safeMessage}. You can undo all changes or ask me to retry.` + }) + break + } + } + + // --- Post-execution validation --- + const validationResult = journeyUpdated + ? await validateJourney(input.journeyId) + : null + + yield msg({ + type: 'done', + journeyUpdated, + turnId, + validation: validationResult + }) + }, + resolve: (event) => event + }) +) + +// --------------------------------------------------------------------------- +// Mutation: journeyAiChatUndo +// --------------------------------------------------------------------------- + +builder.mutationField('journeyAiChatUndo', (t) => + t.withAuth({ isAuthenticated: true }).field({ + type: 'Boolean', + nullable: false, + args: { + turnId: t.arg.string({ required: true }) + }, + resolve: async (_root, { turnId }, context) => { + const entry = await getSnapshot(turnId) + + if (entry == null) { + throw new GraphQLError('Undo snapshot not found or expired.') + } + + if (entry.userId !== context.user.id) { + throw new GraphQLError('Permission denied.') + } + + if (entry.journeyId == null) { + throw new GraphQLError('Invalid snapshot.') + } + + // Verify the user still has permission on the journey + const journey = await prisma.journey.findUnique({ + where: { id: entry.journeyId }, + include: { + userJourneys: true, + team: { include: { userTeams: true } } + } + }) + + if (journey == null) { + throw new GraphQLError('Journey not found.') + } + + if ( + !ability(Action.Update, subject('Journey', journey), context.user) + ) { + throw new GraphQLError('Permission denied.') + } + + await updateSimpleJourney(entry.journeyId, entry.snapshot) + await deleteSnapshot(turnId) + + return true + } + }) +) + +// --------------------------------------------------------------------------- +// Mutation: journeyAiChatExecutePlan +// --------------------------------------------------------------------------- + +builder.mutationField('journeyAiChatExecutePlan', (t) => + t.withAuth({ isAuthenticated: true }).field({ + type: 'Boolean', + nullable: false, + args: { + turnId: t.arg.string({ required: true }) + }, + resolve: async (_root, { turnId }, context) => { + const entry = await getSnapshot(turnId) + + if (entry == null) { + throw new GraphQLError('Plan snapshot not found or expired.') + } + + if (entry.userId !== context.user.id) { + throw new GraphQLError('Permission denied.') + } + + if (entry.plan == null || entry.plan.length === 0) { + throw new GraphQLError('No plan to execute.') + } + + // Verify the user still has permission + const journey = await prisma.journey.findUnique({ + where: { id: entry.journeyId }, + include: { + userJourneys: true, + team: { include: { userTeams: true } } + } + }) + + if (journey == null) { + throw new GraphQLError('Journey not found.') + } + + if ( + !ability(Action.Update, subject('Journey', journey), context.user) + ) { + throw new GraphQLError('Permission denied.') + } + + // Seed card ID map from current journey state + const planJourney = await getAgentJourney(entry.journeyId) + seedCardIdMap( + planJourney.cards.map((c) => ({ simpleId: c.simpleId, blockId: c.blockId })) + ) + + // Execute all plan operations + for (const op of entry.plan) { + await executeOperation(op, entry.journeyId) + } + + // Clear the plan but keep snapshot for undo + await updateSnapshotPlan(turnId, null) + + return true + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/journeyAiChat/snapshotStore.ts b/apis/api-journeys-modern/src/schema/journeyAiChat/snapshotStore.ts new file mode 100644 index 00000000000..033a304de7a --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiChat/snapshotStore.ts @@ -0,0 +1,79 @@ +import Redis from 'ioredis' + +import type { PlanOperation } from '@core/shared/ai/agentJourneyTypes' +import type { JourneySimple } from '@core/shared/ai/journeySimpleTypes' +import { + connection, + type RedisConnectionConfig +} from '@core/yoga/redis/connection' + +export interface SnapshotEntry { + snapshot: JourneySimple + userId: string + journeyId: string + plan: PlanOperation[] | null +} + +const EXPIRY_SECONDS = 3600 + +function buildKey(turnId: string): string { + return `journeyAiChat:undo:${turnId}` +} + +const isUrlConfig = (cfg: RedisConnectionConfig): cfg is { url: string } => + 'url' in cfg + +let redis: Redis | null = null + +function getRedis(): Redis { + if (redis != null) return redis + + redis = isUrlConfig(connection) + ? new Redis(connection.url) + : new Redis({ host: connection.host, port: connection.port }) + + return redis +} + +export async function saveSnapshot( + turnId: string, + entry: SnapshotEntry +): Promise { + await getRedis().set( + buildKey(turnId), + JSON.stringify(entry), + 'EX', + EXPIRY_SECONDS + ) +} + +export async function getSnapshot( + turnId: string +): Promise { + const raw = await getRedis().get(buildKey(turnId)) + if (raw == null) return null + return JSON.parse(raw) as SnapshotEntry +} + +export async function deleteSnapshot(turnId: string): Promise { + await getRedis().del(buildKey(turnId)) +} + +export async function updateSnapshotPlan( + turnId: string, + plan: PlanOperation[] | null +): Promise { + const client = getRedis() + const key = buildKey(turnId) + + const raw = await client.get(key) + if (raw == null) return + + const entry = JSON.parse(raw) as SnapshotEntry + entry.plan = plan + + const ttl = await client.ttl(key) + if (ttl <= 0) return + + await client.set(key, JSON.stringify(entry), 'EX', ttl) +} diff --git a/apis/api-journeys-modern/src/schema/journeyAiChat/systemPrompt.ts b/apis/api-journeys-modern/src/schema/journeyAiChat/systemPrompt.ts new file mode 100644 index 00000000000..4182376a8de --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiChat/systemPrompt.ts @@ -0,0 +1,258 @@ +export const systemPrompt = `You are an AI journey editor for Next Steps, helping non-technical creators build and modify interactive journeys through natural language. + +## Your Role +You help users create and edit journeys — interactive, card-based experiences. Each journey consists of cards that display content (headings, text, images, videos, buttons, polls) and connect to each other via navigation actions. + +## CRITICAL: Act Immediately +- ALWAYS call tools in the SAME turn as your response. Never say "I will do X" without actually doing it. +- Do NOT describe what you plan to do and then stop. Call search_images and submit_plan in the same response. +- Keep text responses to 1-2 sentences MAX, then immediately call tools. +- If the user asks you to create or modify something, DO IT — don't just talk about it. + +## How You Work +1. Read the current journey state (provided below) to understand what exists +2. If creating or rewriting a journey, call search_images FIRST with relevant queries (one per card) to get background image URLs +3. IMMEDIATELY call submit_plan with your changes — include backgroundImage on every card using the URLs from step 2 +4. The server will execute your plan and show the user live progress +5. Steps 2 and 3 MUST happen in the same turn as your text response — never defer to a second turn + +## Tools Available +- **submit_plan**: Submit a structured list of operations. Always use this — never describe changes without planning them. +- **search_images**: Search for images. Always use this before adding any image — never invent image URLs. Accepts up to 5 search queries at once. + +## When to Use Which Operation +- **generate_journey**: Use when the journey has 0 cards (building from scratch) or when rewriting more than half the cards. This replaces the entire journey atomically. ALWAYS call search_images FIRST to get image URLs, then include backgroundImage on each card in the generate_journey plan. You cannot use update_card after generate_journey because you won't have the new block IDs. +- **Surgical tools** (create_card, add_block, update_block, etc.): Use for targeted edits to existing journeys — adding a block, changing text, deleting a card. +- Never use create_card one-by-one to build a journey from scratch — use generate_journey instead. + +## Card IDs +- Use descriptive, semantic names: "card-welcome", "card-results", "card-thank-you" +- Never use positional names like "card-1", "card-2" +- When creating new cards, ensure the ID doesn't collide with existing card IDs + +## Block References +- The journey state includes blockId on every card and content block +- Use these blockIds when calling update_block, delete_block, or other surgical tools +- blockIds are stable Prisma IDs — they don't change when content changes + +## Navigation Rules +- Every non-video card should have at least one navigation path (button, poll, or defaultNextCard) +- The last card in a flow can use a URL action instead of card navigation +- Video cards only need a defaultNextCard + +## Content Rules +- A card with a video content block must have video as its ONLY content block +- A card cannot have both backgroundImage and backgroundVideo +- Poll options require at least 2 options, each with an action +- Multiselect options are text-only — they do NOT support navigation actions. Use poll blocks for options that navigate to different cards. + +## Operation Descriptions +Write descriptions for non-technical users. Reference cards by their heading text (e.g. "the Welcome card"), not by ID. Describe what changes in plain English. Never expose block type names, field names, or card IDs. + +## Response Style +- Write 1-2 sentences MAX, then IMMEDIATELY call tools. Do not write long explanations. +- Never say "I will" or "Let me" without calling tools in the same turn +- Never repeat what the user said back to them +- Never list every operation in text — the plan card shows that +- Never apologize or explain errors at length — just fix and retry + +## Limits +- Maximum 20 cards per journey + +## Example Journey (2 cards) +\`\`\`json +{ + "title": "Welcome Flow", + "description": "A simple onboarding journey", + "cards": [ + { + "id": "card-welcome", + "content": [ + { "type": "heading", "text": "Welcome!", "variant": "h3" }, + { "type": "text", "text": "We're glad you're here.", "variant": "body1" }, + { "type": "button", "text": "Get Started", "action": { "kind": "navigate", "cardId": "card-thanks" } } + ] + }, + { + "id": "card-thanks", + "content": [ + { "type": "heading", "text": "Thank You!", "variant": "h3" }, + { "type": "text", "text": "Enjoy your journey.", "variant": "body1" }, + { "type": "button", "text": "Visit Website", "action": { "kind": "url", "url": "https://example.com" } } + ] + } + ] +} +\`\`\` + +## Operation Schemas (MUST follow exactly) + +### generate_journey — Replace the entire journey (use for new journeys or major rewrites) +Call search_images FIRST, then include backgroundImage on each card using the returned URLs. +\`\`\`json +{ + "id": "op-0", + "description": "Create a new journey about cats with 3 cards", + "tool": "generate_journey", + "args": { + "simple": { + "title": "Cat Journey", + "description": "Learn about cats", + "cards": [ + { + "id": "card-welcome", + "backgroundImage": { "src": "", "alt": "A cute cat" }, + "content": [ + { "type": "heading", "text": "Welcome!", "variant": "h3" }, + { "type": "text", "text": "What kind of cat person are you?", "variant": "body1" }, + { "type": "poll", "options": [ + { "text": "Indoor cats", "action": { "kind": "navigate", "cardId": "card-indoor" } }, + { "text": "Outdoor cats", "action": { "kind": "navigate", "cardId": "card-outdoor" } }, + { "text": "Both!", "action": { "kind": "navigate", "cardId": "card-thanks" } } + ]} + ] + }, + { + "id": "card-indoor", + "backgroundImage": { "src": "", "alt": "Indoor cat" }, + "content": [ + { "type": "heading", "text": "Indoor Cats", "variant": "h3" }, + { "type": "text", "text": "Indoor cats are great companions.", "variant": "body1" }, + { "type": "button", "text": "Next", "action": { "kind": "navigate", "cardId": "card-thanks" } } + ] + }, + { + "id": "card-outdoor", + "backgroundImage": { "src": "", "alt": "Outdoor cat" }, + "content": [ + { "type": "heading", "text": "Outdoor Cats", "variant": "h3" }, + { "type": "text", "text": "Outdoor cats love adventure.", "variant": "body1" }, + { "type": "button", "text": "Next", "action": { "kind": "navigate", "cardId": "card-thanks" } } + ] + }, + { + "id": "card-thanks", + "backgroundImage": { "src": "", "alt": "Happy cat" }, + "content": [ + { "type": "heading", "text": "Thanks!", "variant": "h3" }, + { "type": "text", "text": "Enjoy your cat journey.", "variant": "body1" } + ] + } + ] + } + } +} +\`\`\` +**CRITICAL**: The journey object MUST be nested under \`args.simple\` — not directly in \`args\`. +**CRITICAL**: Poll options that ask the user to choose a path MUST navigate to DIFFERENT cards — each option should go to its own card. Never point all poll options to the same card. + +### update_block — Change text, variant, label, or other properties of an existing block +\`\`\`json +{ + "id": "op-1", + "description": "Update the body text on the Welcome card", + "tool": "update_block", + "args": { + "blockId": "", + "updates": { "text": "New body text here" } + } +} +\`\`\` +The \`updates\` object accepts: \`text\` (for TypographyBlock content or ButtonBlock label), \`variant\` (for TypographyBlock), \`src\`/\`alt\` (for ImageBlock), \`label\`/\`placeholder\`/\`hint\`/\`inputType\` (for TextResponseBlock), \`action\` (for ButtonBlock/RadioOptionBlock), \`spacing\` (for SpacerBlock). + +### add_block — Add a new block to a card +\`\`\`json +{ + "id": "op-2", + "description": "Add more detail text to the Welcome card", + "tool": "add_block", + "args": { + "cardBlockId": "", + "block": { "type": "text", "text": "Additional details here.", "variant": "body1" }, + "position": 2 + } +} +\`\`\` +Note: \`cardBlockId\` is the CardBlock ID (child of StepBlock), NOT the StepBlock ID. Find it in the journey state. + +### delete_block +\`\`\`json +{ + "id": "op-3", + "description": "Remove the subtitle from the Welcome card", + "tool": "delete_block", + "args": { "blockId": "" } +} +\`\`\` + +### create_card +\`\`\`json +{ + "id": "op-4", + "description": "Add a new FAQ card after the Welcome card", + "tool": "create_card", + "args": { + "card": { + "id": "card-faq", + "content": [ + { "type": "heading", "text": "FAQ", "variant": "h3" }, + { "type": "text", "text": "Common questions answered.", "variant": "body1" }, + { "type": "button", "text": "Back", "action": { "kind": "navigate", "cardId": "card-welcome" } } + ] + }, + "insertAfterCard": "" + } +} +\`\`\` + +### update_card — Change card-level properties (background color, default navigation) +\`\`\`json +{ + "id": "op-5", + "description": "Set a dark background on the Welcome card", + "tool": "update_card", + "args": { "blockId": "", "backgroundColor": "#1A1A2E" } +} +\`\`\` +**update_card supports**: backgroundColor, backgroundImage, defaultNextCard, x, y. It does NOT support backgroundVideo — that can only be set via generate_journey. +To add a background image, use search_images FIRST to get URLs, then: +\`\`\`json +{ "tool": "update_card", "args": { "blockId": "", "backgroundImage": { "src": "", "alt": "description" } } } +\`\`\` +To remove a background image, set backgroundImage to null: +\`\`\`json +{ "tool": "update_card", "args": { "blockId": "", "backgroundImage": null } } +\`\`\` + +### delete_card +\`\`\`json +{ + "id": "op-6", + "description": "Remove the FAQ card", + "tool": "delete_card", + "args": { "cardId": "", "redirectTo": "" } +} +\`\`\` + +### update_journey_settings +\`\`\`json +{ + "id": "op-7", + "description": "Update the journey title", + "tool": "update_journey_settings", + "args": { "title": "My New Title" } +} +\`\`\` + +## Image Rules +- ALWAYS call search_images BEFORE submitting a plan that includes any image +- If search_images returns an error, tell the user images are unavailable — do NOT retry or invent URLs +- Never fabricate image URLs — only use URLs returned by search_images + +## Critical Rules for Operations +1. ALWAYS get blockId/cardBlockId values from the journey state — never guess or fabricate them +2. For update_block, put the blockId inside \`args\`, not at the top level +3. For add_block, use \`cardBlockId\` (the CardBlock ID), not the StepBlock ID +4. Every operation needs: id, description, tool, args +5. The \`cardId\` field at the top level of an operation is OPTIONAL metadata for UI highlighting — it is NOT the target of the operation +` diff --git a/apis/api-journeys-modern/src/schema/journeyAiChat/tools/searchImages.ts b/apis/api-journeys-modern/src/schema/journeyAiChat/tools/searchImages.ts new file mode 100644 index 00000000000..9d859467fa8 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiChat/tools/searchImages.ts @@ -0,0 +1,94 @@ +import { tool } from 'ai' +import { z } from 'zod' + +import { env } from '../../../env' + +interface UnsplashSearchResponse { + results: Array<{ + id: string + urls: { raw: string } + alt_description: string | null + user: { + name: string + links: { html: string } + } + }> +} + +interface SearchImagesResult { + results: Array<{ + query: string + images: Array<{ + id: string + src: string + alt: string + photographer: string + photographerUrl: string + }> + }> +} + +async function searchUnsplash( + query: string +): Promise { + const url = `https://api.unsplash.com/search/photos?query=${encodeURIComponent(query)}&per_page=3` + const response = await fetch(url, { + headers: { + Authorization: `Client-ID ${env.UNSPLASH_ACCESS_KEY}` + } + }) + + if (!response.ok) { + throw new Error( + `Unsplash API error: ${response.status} ${response.statusText}` + ) + } + + return response.json() as Promise +} + +export const searchImagesTool = tool({ + description: + 'Search for images on Unsplash. Use before adding any image to a journey. Accepts up to 5 search queries at once. Returns an error if the image service is unavailable.', + inputSchema: z.object({ + queries: z + .array(z.string()) + .min(1) + .max(5) + .describe('Search queries for images') + }), + execute: async ({ + queries + }: { + queries: string[] + }): Promise => { + if (env.UNSPLASH_ACCESS_KEY == null) { + return { error: 'Image search is not available. Do not retry — suggest the user add images manually.' } + } + + const settled = await Promise.allSettled( + queries.map(async (query) => { + const data = await searchUnsplash(query) + return { + query, + images: data.results.map((photo) => ({ + id: photo.id, + src: photo.urls.raw, + alt: photo.alt_description ?? '', + photographer: photo.user.name, + photographerUrl: photo.user.links.html + })) + } + }) + ) + + return { + results: settled + .filter( + (r): r is PromiseFulfilledResult => + r.status === 'fulfilled' + ) + .map((r) => r.value) + } + } +}) diff --git a/apis/api-journeys-modern/src/schema/schema.ts b/apis/api-journeys-modern/src/schema/schema.ts index 509d23220cc..b0f2df0a52e 100644 --- a/apis/api-journeys-modern/src/schema/schema.ts +++ b/apis/api-journeys-modern/src/schema/schema.ts @@ -10,6 +10,7 @@ import './host' import './googleSheetsSync' import './integration' import './journey' +import './journeyAiChat' import './journeyAiTranslate' import './journeyCollection' import './journeyEventsExportLog' diff --git a/apps/journeys-admin/__generated__/JourneyAiChatCreateSubscription.ts b/apps/journeys-admin/__generated__/JourneyAiChatCreateSubscription.ts new file mode 100644 index 00000000000..c10f08c65ed --- /dev/null +++ b/apps/journeys-admin/__generated__/JourneyAiChatCreateSubscription.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { JourneyAiChatInput } from "./globalTypes"; + +// ==================================================== +// GraphQL subscription operation: JourneyAiChatCreateSubscription +// ==================================================== + +export interface JourneyAiChatCreateSubscription_journeyAiChatCreateSubscription { + __typename: "JourneyAiChatMessage"; + type: string | null; + text: string | null; + operations: string | null; + operationId: string | null; + status: string | null; + turnId: string | null; + journeyUpdated: boolean | null; + requiresConfirmation: boolean | null; + name: string | null; + args: string | null; + summary: string | null; + cardId: string | null; + error: string | null; + validation: string | null; +} + +export interface JourneyAiChatCreateSubscription { + journeyAiChatCreateSubscription: JourneyAiChatCreateSubscription_journeyAiChatCreateSubscription; +} + +export interface JourneyAiChatCreateSubscriptionVariables { + input: JourneyAiChatInput; +} diff --git a/apps/journeys-admin/__generated__/globalTypes.ts b/apps/journeys-admin/__generated__/globalTypes.ts index 47d4d4d4700..3c1dcde2933 100644 --- a/apps/journeys-admin/__generated__/globalTypes.ts +++ b/apps/journeys-admin/__generated__/globalTypes.ts @@ -153,6 +153,11 @@ export enum IntegrationType { growthSpaces = "growthSpaces", } +export enum JourneyAiChatPreferredTier { + free = "free", + premium = "premium", +} + export enum JourneyMenuButtonIcon { chevronDown = "chevronDown", ellipsis = "ellipsis", @@ -580,6 +585,21 @@ export interface IntegrationGrowthSpacesUpdateInput { accessSecret: string; } +export interface JourneyAiChatHistoryMessage { + role: string; + content: string; +} + +export interface JourneyAiChatInput { + journeyId: string; + message: string; + history: JourneyAiChatHistoryMessage[]; + turnId?: string | null; + contextCardId?: string | null; + preferredTier?: JourneyAiChatPreferredTier | null; + languageName?: string | null; +} + export interface JourneyCollectionCreateInput { id?: string | null; teamId: string; diff --git a/apps/journeys-admin/pages/journeys/[journeyId]/ai-chat.tsx b/apps/journeys-admin/pages/journeys/[journeyId]/ai-chat.tsx new file mode 100644 index 00000000000..e3995097beb --- /dev/null +++ b/apps/journeys-admin/pages/journeys/[journeyId]/ai-chat.tsx @@ -0,0 +1,137 @@ +import { useQuery } from '@apollo/client' +import { GetServerSidePropsContext } from 'next' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import { NextSeo } from 'next-seo' +import { ReactElement } from 'react' + +import { + GetAdminJourney, + GetAdminJourneyVariables +} from '../../../__generated__/GetAdminJourney' +import { + GetSSRAdminJourney, + GetSSRAdminJourneyVariables +} from '../../../__generated__/GetSSRAdminJourney' +import { AccessDenied } from '../../../src/components/AccessDenied' +import { AiEditor } from '../../../src/components/AiEditor' +import { useAuth } from '../../../src/libs/auth' +import { + getAuthTokens, + redirectToLogin, + toUser +} from '../../../src/libs/auth/getAuthTokens' +import { initAndAuthApp } from '../../../src/libs/initAndAuthApp' +import { GET_ADMIN_JOURNEY, GET_SSR_ADMIN_JOURNEY } from '../[journeyId]' + +function AiChatPage({ status }): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + const router = useRouter() + const { user } = useAuth() + const { data } = useQuery( + GET_ADMIN_JOURNEY, + { + variables: { id: router.query.journeyId as string } + } + ) + + if (status === 'noAccess') { + return ( + <> + + + + ) + } + + return ( + <> + + + + ) +} + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const journeyId = Array.isArray(ctx.query?.journeyId) + ? ctx.query.journeyId[0] + : ctx.query?.journeyId + + if (journeyId == null) return { notFound: true as const } + + const tokens = await getAuthTokens(ctx) + if (tokens == null) return redirectToLogin(ctx) + + const user = toUser(tokens) + const { apolloClient, flags, redirect, translations } = await initAndAuthApp({ + user, + locale: ctx.locale, + resolvedUrl: ctx.resolvedUrl + }) + + if (redirect != null) return { redirect } + + try { + const { data } = await apolloClient.query< + GetSSRAdminJourney, + GetSSRAdminJourneyVariables + >({ + query: GET_SSR_ADMIN_JOURNEY, + variables: { + id: journeyId + } + }) + + if (data.journey?.template === true) { + return { + redirect: { + permanent: false, + destination: `/publisher/${data.journey?.id}` + } + } + } + } catch (error) { + if ((error as Error).message === 'journey not found') { + return { + redirect: { + permanent: false, + destination: '/' + } + } + } + if ((error as Error).message === 'user is not allowed to view journey') { + return { + props: { + status: 'noAccess', + userSerialized: JSON.stringify(user), + ...translations, + flags, + initialApolloState: apolloClient.cache.extract() + } + } + } + throw error + } + + return { + props: { + status: 'success', + userSerialized: JSON.stringify(user), + ...translations, + flags, + initialApolloState: apolloClient.cache.extract() + } + } +} + +export default AiChatPage diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/AiChatPanel.tsx b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/AiChatPanel.tsx new file mode 100644 index 00000000000..f3538629ff2 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/AiChatPanel.tsx @@ -0,0 +1,184 @@ +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' +import Avatar from '@mui/material/Avatar' +import Box from '@mui/material/Box' +import Stack from '@mui/material/Stack' +import { keyframes } from '@mui/material/styles' +import { ReactElement, RefObject, useEffect, useRef } from 'react' + +import { + AiChatMessage, + AiChatStatus, + AiPlan +} from '../AiEditor' + +import { ChatInput } from './ChatInput' +import { MessageBubble } from './MessageBubble' +import { ModelIndicator } from './ModelIndicator' +import { PlanCard } from './PlanCard' +import { StarterSuggestions } from './StarterSuggestions' + +const bounce = keyframes` + 0%, 80%, 100% { transform: scale(0); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +` + +function ThinkingIndicator(): ReactElement { + return ( + + + + + + {[0, 1, 2].map((i) => ( + + ))} + + + ) +} + +interface AiChatPanelProps { + messages: AiChatMessage[] + plans: AiPlan[] + status: AiChatStatus + selectedCardId: string | null + selectedCardLabel?: string + inputRef: RefObject + onSendMessage: (content: string) => void + onStopGeneration: () => void + onSuggestionClick: (suggestion: string) => void + onUndoMessage: (messageId: string) => void + onDismissContext: () => void + onConfirmPlan?: (planId: string) => void + onRejectPlan?: (planId: string) => void +} + +export function AiChatPanel({ + messages, + plans, + status, + selectedCardId, + selectedCardLabel, + inputRef, + onSendMessage, + onStopGeneration, + onSuggestionClick, + onUndoMessage, + onDismissContext, + onConfirmPlan, + onRejectPlan +}: AiChatPanelProps): ReactElement { + const messagesEndRef = useRef(null) + const hasMessages = messages.length > 0 + const lastMessage = messages[messages.length - 1] + const showThinking = + status === 'thinking' && + (lastMessage == null || lastMessage.role === 'user') + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [messages.length, showThinking, plans.length]) + + return ( + + + {!hasMessages ? ( + + + + ) : ( + <> + {messages.map((message) => ( + + {message.role === 'plan' && message.plan != null ? ( + + + + ) : ( + onUndoMessage(message.id) + : undefined + } + /> + )} + + ))} + {showThinking && } +
+ + )} + + + + + + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ChatInput/ChatInput.tsx b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ChatInput/ChatInput.tsx new file mode 100644 index 00000000000..b252f12bdd9 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ChatInput/ChatInput.tsx @@ -0,0 +1,160 @@ +import CloseIcon from '@mui/icons-material/Close' +import SendIcon from '@mui/icons-material/Send' +import StopIcon from '@mui/icons-material/Stop' +import Box from '@mui/material/Box' +import Chip from '@mui/material/Chip' +import IconButton from '@mui/material/IconButton' +import InputBase from '@mui/material/InputBase' +import Stack from '@mui/material/Stack' +import { ReactElement, RefObject, useCallback, useState } from 'react' + +import { AiChatStatus } from '../../AiEditor' + +interface ChatInputProps { + inputRef: RefObject + status: AiChatStatus + selectedCardId: string | null + selectedCardLabel?: string + onSendMessage: (content: string) => void + onStopGeneration: () => void + onDismissContext: () => void +} + +export function ChatInput({ + inputRef, + status, + selectedCardId, + selectedCardLabel, + onSendMessage, + onStopGeneration, + onDismissContext +}: ChatInputProps): ReactElement { + const [value, setValue] = useState('') + const isAiActive = status === 'thinking' || status === 'executing' + const hasText = value.trim().length > 0 + + const handleSubmit = useCallback(() => { + const trimmed = value.trim() + if (trimmed.length === 0 || isAiActive) return + onSendMessage(trimmed) + setValue('') + }, [value, isAiActive, onSendMessage]) + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key !== 'Enter') return + if (event.shiftKey) return + event.preventDefault() + handleSubmit() + }, + [handleSubmit] + ) + + return ( + + {selectedCardId != null && ( + + } + sx={{ + mb: 1, + bgcolor: '#EBF3FE', + color: '#1565C0', + fontSize: 12, + height: 26, + '& .MuiChip-deleteIcon': { + color: '#1565C0', + '&:hover': { color: '#0D47A1' } + } + }} + /> + )} + + setValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Describe what you want to change..." + multiline + maxRows={4} + disabled={isAiActive} + sx={{ + flex: 1, + fontSize: 14, + '& .MuiInputBase-input': { + py: 1 + } + }} + inputProps={{ + 'aria-label': 'Chat input' + }} + /> + {isAiActive ? ( + + + + ) : ( + + + + )} + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ChatInput/index.ts b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ChatInput/index.ts new file mode 100644 index 00000000000..d365f02946a --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ChatInput/index.ts @@ -0,0 +1 @@ +export { ChatInput } from './ChatInput' diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/MessageBubble/MessageBubble.tsx b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/MessageBubble/MessageBubble.tsx new file mode 100644 index 00000000000..ffde28db395 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/MessageBubble/MessageBubble.tsx @@ -0,0 +1,104 @@ +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' +import UndoIcon from '@mui/icons-material/Undo' +import Avatar from '@mui/material/Avatar' +import Box from '@mui/material/Box' +import IconButton from '@mui/material/IconButton' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { ReactElement, useState } from 'react' + +import { AiChatMessage } from '../../AiEditor' + +interface MessageBubbleProps { + message: AiChatMessage + onUndo?: () => void +} + +export function MessageBubble({ + message, + onUndo +}: MessageBubbleProps): ReactElement { + const [hovered, setHovered] = useState(false) + const isUser = message.role === 'user' + + if (isUser) { + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + + {message.content} + + + {onUndo != null && hovered && ( + + + + )} + + + ) + } + + return ( + + + + + + {message.content} + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/MessageBubble/index.ts b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/MessageBubble/index.ts new file mode 100644 index 00000000000..824b02078d0 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/MessageBubble/index.ts @@ -0,0 +1 @@ +export { MessageBubble } from './MessageBubble' diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ModelIndicator/ModelIndicator.tsx b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ModelIndicator/ModelIndicator.tsx new file mode 100644 index 00000000000..006e9830106 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ModelIndicator/ModelIndicator.tsx @@ -0,0 +1,57 @@ +import BoltIcon from '@mui/icons-material/Bolt' +import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { useTranslation } from 'next-i18next' +import { ReactElement } from 'react' + +interface ModelIndicatorProps { + isConnected: boolean +} + +export function ModelIndicator({ + isConnected +}: ModelIndicatorProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + if (isConnected) { + return ( + + + + {t('Powered by Claude Sonnet 4')} · {t('Settings')} → + + + ) + } + + return ( + + + + {t('AI-powered')} · {t('Use your own Claude key')} → + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ModelIndicator/index.ts b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ModelIndicator/index.ts new file mode 100644 index 00000000000..8590e39789f --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ModelIndicator/index.ts @@ -0,0 +1 @@ +export { ModelIndicator } from './ModelIndicator' diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/PlanCard/PlanCard.tsx b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/PlanCard/PlanCard.tsx new file mode 100644 index 00000000000..41d47cf7408 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/PlanCard/PlanCard.tsx @@ -0,0 +1,200 @@ +import CancelIcon from '@mui/icons-material/Cancel' +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import Chip from '@mui/material/Chip' +import CircularProgress from '@mui/material/CircularProgress' +import Collapse from '@mui/material/Collapse' +import IconButton from '@mui/material/IconButton' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { useTranslation } from 'next-i18next' +import { ReactElement, useEffect, useState } from 'react' + +import { AiPlan, AiPlanOperation } from '../../AiEditor' + +interface PlanCardProps { + plan: AiPlan + onConfirm?: (planId: string) => void + onReject?: (planId: string) => void +} + +type PlanStatus = AiPlan['status'] + +function getStatusBadge( + status: PlanStatus, + operationCount: number +): { label: string; color: string; bgcolor: string } { + switch (status) { + case 'pending': + return { + label: `${operationCount} ops`, + color: '#F0720C', + bgcolor: '#FFF3E6' + } + case 'running': + return { label: 'Running', color: '#C52D3A', bgcolor: '#FDECEE' } + case 'complete': + return { label: 'Complete', color: '#3AA74A', bgcolor: '#EAF7EC' } + case 'failed': + return { label: 'Failed', color: '#B62D1C', bgcolor: '#FDECEE' } + case 'stopped': + return { label: 'Stopped', color: '#F0720C', bgcolor: '#FFF3E6' } + } +} + +function OperationStatusIcon({ + status +}: { + status: AiPlanOperation['status'] +}): ReactElement { + switch (status) { + case 'pending': + return ( + + ) + case 'running': + return + case 'done': + return ( + + ) + case 'failed': + return + } +} + +export function PlanCard({ + plan, + onConfirm, + onReject +}: PlanCardProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + const badge = getStatusBadge(plan.status, plan.operations.length) + const isFinished = + plan.status === 'complete' || + plan.status === 'failed' || + plan.status === 'stopped' + + const [expanded, setExpanded] = useState(true) + + // Auto-collapse when plan finishes + useEffect(() => { + if (isFinished) setExpanded(false) + }, [isFinished]) + + return ( + + setExpanded((prev) => !prev)} + > + + + + + + {t('Execution Plan')} + + + + + + + + {plan.operations.map((operation) => ( + + + + {operation.description} + + + ))} + + + {plan.status === 'pending' && ( + + + + + )} + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/PlanCard/index.ts b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/PlanCard/index.ts new file mode 100644 index 00000000000..92bc91c3092 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/PlanCard/index.ts @@ -0,0 +1 @@ +export { PlanCard } from './PlanCard' diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/StarterSuggestions/StarterSuggestions.tsx b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/StarterSuggestions/StarterSuggestions.tsx new file mode 100644 index 00000000000..6ac44dc362c --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/StarterSuggestions/StarterSuggestions.tsx @@ -0,0 +1,126 @@ +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' +import Box from '@mui/material/Box' +import Chip from '@mui/material/Chip' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { useTranslation } from 'next-i18next' +import { ReactElement } from 'react' + +interface StarterSuggestionsProps { + onSuggestionClick: (suggestion: string) => void +} + +export function StarterSuggestions({ + onSuggestionClick +}: StarterSuggestionsProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + + const suggestionsRow1 = [ + t('Build a 5-card onboarding flow'), + t('Add a poll to card 2') + ] + + const suggestionsRow2 = [ + t('Translate all cards to Spanish'), + t('Add images to each card') + ] + + return ( + + + + {t('AI Journey Editor')} + + + {t('Describe what you want to build or change.')} +
+ {t('The AI will edit your journey in real-time.')} +
+ + + + {suggestionsRow1.map((suggestion) => ( + onSuggestionClick(suggestion)} + aria-label={suggestion} + tabIndex={0} + sx={{ + borderRadius: '20px', + borderColor: 'divider', + color: 'text.primary', + fontSize: 13, + cursor: 'pointer', + '&:hover': { + bgcolor: '#F5F5F5', + borderColor: 'text.secondary' + } + }} + /> + ))} + + + {suggestionsRow2.map((suggestion) => ( + onSuggestionClick(suggestion)} + aria-label={suggestion} + tabIndex={0} + sx={{ + borderRadius: '20px', + borderColor: 'divider', + color: 'text.primary', + fontSize: 13, + cursor: 'pointer', + '&:hover': { + bgcolor: '#F5F5F5', + borderColor: 'text.secondary' + } + }} + /> + ))} + + +
+ ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/StarterSuggestions/index.ts b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/StarterSuggestions/index.ts new file mode 100644 index 00000000000..cd957e07919 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/StarterSuggestions/index.ts @@ -0,0 +1 @@ +export { StarterSuggestions } from './StarterSuggestions' diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ToolCallCard/ToolCallCard.tsx b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ToolCallCard/ToolCallCard.tsx new file mode 100644 index 00000000000..f5bd54a4e07 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ToolCallCard/ToolCallCard.tsx @@ -0,0 +1,45 @@ +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import CircularProgress from '@mui/material/CircularProgress' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { ReactElement } from 'react' + +type ToolCallStatus = 'running' | 'complete' + +interface ToolCallCardProps { + label: string + status: ToolCallStatus +} + +export function ToolCallCard({ + label, + status +}: ToolCallCardProps): ReactElement { + return ( + + {status === 'running' ? ( + + ) : ( + + )} + + {label} + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ToolCallCard/index.ts b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ToolCallCard/index.ts new file mode 100644 index 00000000000..2b117fa68b9 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/ToolCallCard/index.ts @@ -0,0 +1 @@ +export { ToolCallCard } from './ToolCallCard' diff --git a/apps/journeys-admin/src/components/AiEditor/AiChatPanel/index.ts b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/index.ts new file mode 100644 index 00000000000..1595fd83910 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChatPanel/index.ts @@ -0,0 +1 @@ +export { AiChatPanel } from './AiChatPanel' diff --git a/apps/journeys-admin/src/components/AiEditor/AiEditor.tsx b/apps/journeys-admin/src/components/AiEditor/AiEditor.tsx new file mode 100644 index 00000000000..fd0d22fd808 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiEditor.tsx @@ -0,0 +1,384 @@ +import { gql, useMutation } from '@apollo/client' +import Box from '@mui/material/Box' +import { + ReactElement, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react' +import { ReactFlowProvider } from 'reactflow' + +import { GetAdminJourney_journey as Journey } from '../../../__generated__/GetAdminJourney' +import { User } from '../../libs/auth/authContext' +import { useJourneyAiChatSubscription } from '../../libs/useJourneyAiChatSubscription/useJourneyAiChatSubscription' + +import { AiChatPanel } from './AiChatPanel' +import { AiEditorToolbar } from './AiEditorToolbar' +import { AiJourneyFlow } from './AiJourneyFlow' + +const EXECUTE_PLAN = gql` + mutation JourneyAiChatExecutePlan($turnId: String!) { + journeyAiChatExecutePlan(turnId: $turnId) + } +` + +const AI_EDITOR_TOOLBAR_HEIGHT = 48 + +export type AiChatStatus = 'idle' | 'thinking' | 'executing' | 'error' + +export interface AiChatMessage { + id: string + role: 'user' | 'assistant' | 'plan' + content: string + timestamp: Date + plan?: AiPlan +} + +export interface AiPlanOperation { + id: string + description: string + status: 'pending' | 'running' | 'done' | 'failed' +} + +export interface AiPlan { + id: string + status: 'pending' | 'running' | 'complete' | 'failed' | 'stopped' + operations: AiPlanOperation[] +} + +interface AiEditorProps { + journey?: Journey + user?: User +} + +export function AiEditor({ journey, user }: AiEditorProps): ReactElement { + const [chatMessages, setChatMessages] = useState([]) + const [status, setStatus] = useState('idle') + const [selectedCardId, setSelectedCardId] = useState(null) + const [plans, setPlans] = useState([]) + const currentAssistantIdRef = useRef(null) + const inputRef = useRef(null) + + const subscription = useJourneyAiChatSubscription() + + // Resolve selected card heading for the context chip + const selectedCardLabel = useMemo(() => { + if (selectedCardId == null || journey?.blocks == null) return undefined + const step = journey.blocks.find( + (b) => b.id === selectedCardId && b.__typename === 'StepBlock' + ) + if (step == null) return undefined + const card = journey.blocks.find( + (b) => b.parentBlockId === step.id && b.__typename === 'CardBlock' + ) + if (card == null) return undefined + const heading = journey.blocks.find( + (b) => + b.parentBlockId === card.id && + b.__typename === 'TypographyBlock' && + ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes( + (b as { variant?: string }).variant ?? '' + ) + ) + return (heading as { content?: string })?.content ?? undefined + }, [selectedCardId, journey?.blocks]) + + const activeCardIds = subscription.activeCardIds + + // Sync subscription state → local status + useEffect(() => { + if (!subscription.isActive && status === 'thinking') { + setStatus('idle') + } + }, [subscription.isActive, status]) + + // Convert subscription messages into assistant chat messages and plans + useEffect(() => { + const subMessages = subscription.messages + if (subMessages.length === 0) return + + // Build assistant text from coalesced text messages + const textParts: string[] = [] + const planMap = new Map() + + for (const msg of subMessages) { + if (msg.type === 'text' && msg.text != null) { + textParts.push(msg.text) + } + + if (msg.type === 'plan' && msg.operations != null) { + try { + const ops = JSON.parse(msg.operations) as Array<{ + id: string + description: string + }> + const planId = msg.turnId ?? crypto.randomUUID() + planMap.set(planId, { + id: planId, + status: msg.requiresConfirmation ? 'pending' : 'running', + operations: ops.map((op) => ({ + id: op.id, + description: op.description, + status: 'pending' + })) + }) + } catch { + // ignore malformed operations + } + } + + if (msg.type === 'plan_progress' && msg.operationId != null) { + for (const plan of planMap.values()) { + const op = plan.operations.find((o) => o.id === msg.operationId) + if (op != null) { + op.status = + msg.status === 'done' + ? 'done' + : msg.status === 'error' + ? 'failed' + : 'running' + } + } + } + + if (msg.type === 'error') { + setStatus('error') + } + + if (msg.type === 'done') { + setStatus('idle') + for (const plan of planMap.values()) { + if (plan.status === 'running') { + plan.status = msg.journeyUpdated ? 'complete' : 'failed' + if (msg.journeyUpdated) { + for (const op of plan.operations) { + if (op.status === 'pending' || op.status === 'running') { + op.status = 'done' + } + } + } + } + } + } + } + + // Update assistant text message in chat + setChatMessages((prev) => { + const updated = [...prev] + + if (textParts.length > 0) { + const assistantText = textParts.join('') + const currentId = currentAssistantIdRef.current + const existingIdx = currentId != null + ? updated.findIndex((m) => m.id === currentId) + : -1 + + if (existingIdx >= 0) { + updated[existingIdx] = { + ...updated[existingIdx], + content: assistantText + } + } else { + const newId = crypto.randomUUID() + currentAssistantIdRef.current = newId + updated.push({ + id: newId, + role: 'assistant', + content: assistantText, + timestamp: new Date() + }) + } + } + + // Upsert plan messages into history + for (const plan of planMap.values()) { + const existingIdx = updated.findIndex( + (m) => m.role === 'plan' && m.plan?.id === plan.id + ) + const planMessage: AiChatMessage = { + id: plan.id, + role: 'plan', + content: '', + timestamp: new Date(), + plan + } + if (existingIdx >= 0) { + updated[existingIdx] = planMessage + } else { + updated.push(planMessage) + } + } + + return updated + }) + + setPlans(Array.from(planMap.values())) + }, [subscription.messages]) + + const handleSendMessage = useCallback( + (content: string) => { + if (journey == null) return + + const userMessage: AiChatMessage = { + id: crypto.randomUUID(), + role: 'user', + content, + timestamp: new Date() + } + setChatMessages((prev) => [...prev, userMessage]) + currentAssistantIdRef.current = null + setStatus('thinking') + + const history = chatMessages.map((m) => ({ + role: m.role, + content: m.content + })) + + const langName = + journey.language?.name?.find(({ primary }) => !primary)?.value ?? + undefined + + subscription.send({ + journeyId: journey.id, + message: content, + history, + contextCardId: selectedCardId ?? undefined, + languageName: langName + }) + }, + [journey, chatMessages, selectedCardId, subscription] + ) + + const handleStopGeneration = useCallback(() => { + subscription.stop() + setStatus('idle') + }, [subscription]) + + const handleSuggestionClick = useCallback( + (suggestion: string) => { + handleSendMessage(suggestion) + }, + [handleSendMessage] + ) + + const handleUndoMessage = useCallback((messageId: string) => { + setChatMessages((prev) => { + const index = prev.findIndex((m) => m.id === messageId) + if (index === -1) return prev + return prev.slice(0, index) + }) + }, []) + + const handleDismissContext = useCallback(() => { + setSelectedCardId(null) + }, []) + + const [executePlan] = useMutation(EXECUTE_PLAN, { + refetchQueries: ['GetAdminJourney'] + }) + + const handleConfirmPlan = useCallback( + async (planId: string) => { + setPlans((prev) => + prev.map((p) => (p.id === planId ? { ...p, status: 'running' as const } : p)) + ) + setStatus('executing') + try { + await executePlan({ variables: { turnId: planId } }) + setPlans((prev) => + prev.map((p) => + p.id === planId + ? { + ...p, + status: 'complete' as const, + operations: p.operations.map((op) => ({ + ...op, + status: 'done' as const + })) + } + : p + ) + ) + setStatus('idle') + } catch { + setPlans((prev) => + prev.map((p) => + p.id === planId ? { ...p, status: 'failed' as const } : p + ) + ) + setStatus('error') + } + }, + [executePlan] + ) + + const handleRejectPlan = useCallback((planId: string) => { + setPlans((prev) => + prev.map((p) => + p.id === planId ? { ...p, status: 'stopped' as const } : p + ) + ) + }, []) + + return ( + + 0} + /> + + + + + + + + + + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiEditorToolbar/AiEditorToolbar.tsx b/apps/journeys-admin/src/components/AiEditor/AiEditorToolbar/AiEditorToolbar.tsx new file mode 100644 index 00000000000..17491956bbf --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiEditorToolbar/AiEditorToolbar.tsx @@ -0,0 +1,132 @@ +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' +import Button from '@mui/material/Button' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import DialogContentText from '@mui/material/DialogContentText' +import DialogTitle from '@mui/material/DialogTitle' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import { ReactElement, useCallback, useState } from 'react' + +interface AiEditorToolbarProps { + journeyTitle?: string + hasMessages: boolean +} + +export function AiEditorToolbar({ + journeyTitle, + hasMessages +}: AiEditorToolbarProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + const router = useRouter() + const [confirmOpen, setConfirmOpen] = useState(false) + + const journeyId = + typeof router.query.journeyId === 'string' + ? router.query.journeyId + : undefined + + const handleEditManuallyClick = useCallback(() => { + if (hasMessages) { + setConfirmOpen(true) + return + } + void navigateToManualEditor() + }, [hasMessages]) + + function navigateToManualEditor(): Promise { + if (journeyId == null) return Promise.resolve(false) + return router.push(`/journeys/${journeyId}`) + } + + function handleConfirmLeave(): void { + setConfirmOpen(false) + void navigateToManualEditor() + } + + function handleCancelLeave(): void { + setConfirmOpen(false) + } + + return ( + <> + + + + + {journeyTitle ?? ''} + + +
+ + + + + {t('Leave AI Editor?')} + + + + {t( + 'Your chat history will be lost if you switch to the manual editor.' + )} + + + + + + + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiEditorToolbar/index.ts b/apps/journeys-admin/src/components/AiEditor/AiEditorToolbar/index.ts new file mode 100644 index 00000000000..11c7ae27b88 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiEditorToolbar/index.ts @@ -0,0 +1 @@ +export { AiEditorToolbar } from './AiEditorToolbar' diff --git a/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/AiJourneyFlow.tsx b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/AiJourneyFlow.tsx new file mode 100644 index 00000000000..7a56f12b9d5 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/AiJourneyFlow.tsx @@ -0,0 +1,228 @@ +import Box from '@mui/material/Box' +import { ReactElement, useCallback, useEffect, useMemo, useRef } from 'react' +import ReactFlow, { + Background, + type Edge, + MarkerType, + type Node, + type ReactFlowInstance, + useEdgesState, + useNodesState +} from 'reactflow' + +import { type TreeBlock } from '@core/journeys/ui/block' +import { type BlockFields_StepBlock as StepBlock } from '@core/journeys/ui/block/__generated__/BlockFields' +import { filterActionBlocks } from '@core/journeys/ui/filterActionBlocks' +import { transformer } from '@core/journeys/ui/transformer' + +import { GetAdminJourney_journey as Journey } from '../../../../__generated__/GetAdminJourney' + +import { AiViewEdge } from './edges/AiViewEdge' +import { layoutNodes } from './libs/layoutNodes' +import { + AiCardPreviewNode, + NODE_HEIGHT, + NODE_WIDTH +} from './nodes/AiCardPreviewNode' + +import 'reactflow/dist/style.css' + +const nodeTypes = { StepBlock: AiCardPreviewNode } +const edgeTypes = { Custom: AiViewEdge } + +interface AiJourneyFlowProps { + journey?: Journey + activeCardIds?: Set + selectedCardId: string | null + onCardSelect: (cardId: string | null) => void +} + +type TreeStepBlock = TreeBlock + +function getEdgeVariant( + block: TreeBlock +): 'default' | 'button' | 'poll' { + if (block.__typename === 'RadioOptionBlock') return 'poll' + if (block.__typename === 'ButtonBlock') return 'button' + return 'default' +} + +function getEdgeLabel(block: TreeBlock): string | undefined { + if ('label' in block && typeof block.label === 'string') return block.label + return undefined +} + +function buildNodesAndEdges( + steps: TreeStepBlock[], + activeCardIds: Set, + selectedCardId: string | null, + onCardSelect: (cardId: string | null) => void, + journey?: Journey +): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = [] + const edges: Edge[] = [] + + for (const step of steps) { + const actionBlocks = filterActionBlocks(step) + + nodes.push({ + id: step.id, + type: 'StepBlock', + position: { x: 0, y: 0 }, + data: { + step, + isSelected: selectedCardId === step.id, + isBeingEdited: activeCardIds.has(step.id), + isCompleted: false, + onCardSelect, + journey + } + }) + + if (step.nextBlockId != null && step.nextBlockId !== step.id) { + const targetExists = steps.some((s) => s.id === step.nextBlockId) + if (targetExists) { + edges.push({ + id: `${step.id}->${step.nextBlockId}`, + source: step.id, + target: step.nextBlockId, + type: 'Custom', + data: { variant: 'default', label: 'Default' }, + markerEnd: { + type: MarkerType.ArrowClosed, + height: 8, + width: 8, + color: '#6D6D7D80' + } + }) + } + } + + for (const block of actionBlocks) { + if (block.action?.__typename !== 'NavigateToBlockAction') continue + const targetId = block.action.blockId + if (targetId === step.id) continue + const targetExists = steps.some((s) => s.id === targetId) + if (!targetExists) continue + + edges.push({ + id: `${block.id}->${targetId}`, + source: step.id, + target: targetId, + type: 'Custom', + data: { + variant: getEdgeVariant(block), + label: getEdgeLabel(block) + }, + markerEnd: { + type: MarkerType.ArrowClosed, + height: 8, + width: 8, + color: '#6D6D7D80' + } + }) + } + } + + return { nodes, edges } +} + +export function AiJourneyFlow({ + journey, + activeCardIds = new Set(), + selectedCardId, + onCardSelect +}: AiJourneyFlowProps): ReactElement { + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const reactFlowRef = useRef(null) + + const steps = useMemo(() => { + if (journey?.blocks == null) return [] + return transformer(journey.blocks) as TreeStepBlock[] + }, [journey?.blocks]) + + const handleInit = useCallback((instance: ReactFlowInstance) => { + reactFlowRef.current = instance + setTimeout(() => instance.fitView({ padding: 0.2 }), 50) + }, []) + + const handlePaneClick = useCallback(() => { + onCardSelect(null) + }, [onCardSelect]) + + // Full layout rebuild when steps change + useEffect(() => { + if (steps.length === 0) { + setNodes([]) + setEdges([]) + return + } + + const { nodes: rawNodes, edges: newEdges } = buildNodesAndEdges( + steps, + activeCardIds, + selectedCardId, + onCardSelect, + journey + ) + + const laidOutNodes = layoutNodes(rawNodes, newEdges, { + nodeWidth: NODE_WIDTH, + nodeHeight: NODE_HEIGHT + }) + + setNodes(laidOutNodes) + setEdges(newEdges) + + if (reactFlowRef.current != null) { + setTimeout(() => reactFlowRef.current?.fitView({ padding: 0.2 }), 100) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [steps, setNodes, setEdges]) + + // Update node data in-place when selection or active editing changes (no re-layout) + useEffect(() => { + setNodes((prev) => + prev.map((node) => ({ + ...node, + data: { + ...node.data, + isSelected: selectedCardId === node.id, + isBeingEdited: activeCardIds.has(node.id), + onCardSelect, + journey + } + })) + ) + }, [selectedCardId, activeCardIds, onCardSelect, journey, setNodes]) + + return ( + + onCardSelect(node.id)} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + nodesDraggable={false} + nodesConnectable={false} + panOnScroll + zoomOnScroll + minZoom={0.2} + maxZoom={4} + fitView + proOptions={{ hideAttribution: true }} + > + + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/edges/AiViewEdge/AiViewEdge.tsx b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/edges/AiViewEdge/AiViewEdge.tsx new file mode 100644 index 00000000000..8e987000267 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/edges/AiViewEdge/AiViewEdge.tsx @@ -0,0 +1,131 @@ +import Box from '@mui/material/Box' +import Typography from '@mui/material/Typography' +import { ReactElement } from 'react' +import { + BaseEdge, + EdgeLabelRenderer, + type EdgeProps, + getBezierPath, + getStraightPath +} from 'reactflow' + +import ArrowRightSmIcon from '@core/shared/ui/icons/ArrowRightSm' +import CursorPointerIcon from '@core/shared/ui/icons/CursorPointer' +import GitBranchIcon from '@core/shared/ui/icons/GitBranch' + +type AiEdgeVariant = 'default' | 'button' | 'poll' + +interface AiViewEdgeData { + variant: AiEdgeVariant + label?: string +} + +const EDGE_COLORS: Record = { + default: '#6D6D7D80', + button: '#6D6D7D30', + poll: '#4c9bf880' +} + +const EDGE_ICONS: Record = { + default: ArrowRightSmIcon, + button: CursorPointerIcon, + poll: GitBranchIcon +} + +const DEFAULT_LABELS: Record = { + default: 'Default', + button: 'Button', + poll: 'Option' +} + +function isHorizontal( + sourceY: number, + targetY: number, + threshold = 5 +): boolean { + return Math.abs(sourceY - targetY) < threshold +} + +export function AiViewEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + markerEnd +}: EdgeProps): ReactElement { + const variant: AiEdgeVariant = data?.variant ?? 'default' + const label = data?.label ?? DEFAULT_LABELS[variant] + const strokeColor = EDGE_COLORS[variant] + const Icon = EDGE_ICONS[variant] + + const horizontal = isHorizontal(sourceY, targetY) + + const [edgePath, labelX, labelY] = horizontal + ? getStraightPath({ + sourceX, + sourceY, + targetX, + targetY + }) + : getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition + }) + + return ( + <> + + + + + + {label} + + + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/edges/AiViewEdge/index.ts b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/edges/AiViewEdge/index.ts new file mode 100644 index 00000000000..4e89cfb5047 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/edges/AiViewEdge/index.ts @@ -0,0 +1 @@ +export { AiViewEdge } from './AiViewEdge' diff --git a/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/index.ts b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/index.ts new file mode 100644 index 00000000000..2afc0e85a15 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/index.ts @@ -0,0 +1 @@ +export { AiJourneyFlow } from './AiJourneyFlow' diff --git a/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/libs/layoutNodes/index.ts b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/libs/layoutNodes/index.ts new file mode 100644 index 00000000000..ec2dee1c84f --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/libs/layoutNodes/index.ts @@ -0,0 +1 @@ +export { layoutNodes } from './layoutNodes' diff --git a/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/libs/layoutNodes/layoutNodes.ts b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/libs/layoutNodes/layoutNodes.ts new file mode 100644 index 00000000000..d742aeb5081 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/libs/layoutNodes/layoutNodes.ts @@ -0,0 +1,60 @@ +import dagre from '@dagrejs/dagre' +import { type Edge, type Node, Position } from 'reactflow' + +const RANK_DIR = 'LR' +const RANK_SEP = 180 +const NODE_SEP = 60 +const EDGE_SEP = 30 + +interface LayoutNodesOptions { + nodeWidth: number + nodeHeight: number +} + +export function layoutNodes( + nodes: Node[], + edges: Edge[], + options: LayoutNodesOptions +): Node[] { + const { nodeWidth, nodeHeight } = options + const graph = new dagre.graphlib.Graph() + + graph.setDefaultEdgeLabel(() => ({})) + graph.setGraph({ + rankdir: RANK_DIR, + ranksep: RANK_SEP, + nodesep: NODE_SEP, + edgesep: EDGE_SEP + }) + + for (const node of nodes) { + graph.setNode(node.id, { + width: node.data.measuredWidth ?? nodeWidth, + height: node.data.measuredHeight ?? nodeHeight + }) + } + + for (const edge of edges) { + graph.setEdge(edge.source, edge.target) + } + + dagre.layout(graph) + + return nodes.map((node) => { + const dagreNode = graph.node(node.id) + if (dagreNode == null) return node + + const width = node.data.measuredWidth ?? nodeWidth + const height = node.data.measuredHeight ?? nodeHeight + + return { + ...node, + position: { + x: dagreNode.x - width / 2, + y: dagreNode.y - height / 2 + }, + sourcePosition: Position.Right, + targetPosition: Position.Left + } + }) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/nodes/AiCardPreviewNode/AiCardPreviewNode.tsx b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/nodes/AiCardPreviewNode/AiCardPreviewNode.tsx new file mode 100644 index 00000000000..c35c2306860 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/nodes/AiCardPreviewNode/AiCardPreviewNode.tsx @@ -0,0 +1,196 @@ +import Box from '@mui/material/Box' +import Stack from '@mui/material/Stack' +import { useTheme } from '@mui/material/styles' +import { type ReactElement, useCallback, useMemo } from 'react' +import { Handle, type NodeProps, Position } from 'reactflow' + +import { type TreeBlock } from '@core/journeys/ui/block' +import { type BlockFields_StepBlock as StepBlock } from '@core/journeys/ui/block/__generated__/BlockFields' +import { BlockRenderer } from '@core/journeys/ui/BlockRenderer' +import { FramePortal } from '@core/journeys/ui/FramePortal' +import { getStepTheme } from '@core/journeys/ui/getStepTheme' +import { JourneyProvider } from '@core/journeys/ui/JourneyProvider' +import { ThemeProvider } from '@core/shared/ui/ThemeProvider' +import { ThemeName } from '@core/shared/ui/themes' + +import { GetAdminJourney_journey as Journey } from '../../../../../../__generated__/GetAdminJourney' + +const CARD_FULL_WIDTH = 380 +const CARD_FULL_HEIGHT = 674 +const SCALE = 0.35 + +const SCALED_WIDTH = Math.round(CARD_FULL_WIDTH * SCALE) +const SCALED_HEIGHT = Math.round(CARD_FULL_HEIGHT * SCALE) + +export const NODE_WIDTH = SCALED_WIDTH + 8 +export const NODE_HEIGHT = SCALED_HEIGHT + 8 + +const SELECTED_COLOR = '#4c9bf8' +const EDITING_COLOR = '#C52D3A' + +interface AiCardPreviewNodeData { + step: TreeBlock + isSelected: boolean + isBeingEdited: boolean + isCompleted: boolean + onCardSelect: (cardId: string | null) => void + journey?: Journey +} + +function getBorderColor(data: AiCardPreviewNodeData): string | undefined { + if (data.isBeingEdited) return EDITING_COLOR + if (data.isSelected) return SELECTED_COLOR + return undefined +} + +function getBoxShadow(data: AiCardPreviewNodeData): string | undefined { + if (data.isBeingEdited) + return `0 0 12px 2px ${EDITING_COLOR}40, 0 0 24px 4px ${EDITING_COLOR}20` + if (data.isSelected) + return `0 0 12px 2px ${SELECTED_COLOR}40, 0 0 24px 4px ${SELECTED_COLOR}20` + return undefined +} + +export function AiCardPreviewNode({ + id, + data +}: NodeProps): ReactElement { + const theme = useTheme() + const borderColor = getBorderColor(data) + const boxShadow = getBoxShadow(data) + + const handleClick = useCallback(() => { + data.onCardSelect(id) + }, [id, data]) + + const stepTheme = useMemo( + () => getStepTheme(data.step, data.journey ?? undefined), + [data.step, data.journey] + ) + + return ( + + {data.isBeingEdited && } + + + + + + + {({ document: _doc }) => ( + + + + + + + + + + )} + + + + + + + ) +} + +function ShimmerOverlay(): ReactElement { + return ( + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/nodes/AiCardPreviewNode/index.ts b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/nodes/AiCardPreviewNode/index.ts new file mode 100644 index 00000000000..483f4ed6d6b --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiJourneyFlow/nodes/AiCardPreviewNode/index.ts @@ -0,0 +1 @@ +export { AiCardPreviewNode, NODE_WIDTH, NODE_HEIGHT } from './AiCardPreviewNode' diff --git a/apps/journeys-admin/src/components/AiEditor/index.ts b/apps/journeys-admin/src/components/AiEditor/index.ts new file mode 100644 index 00000000000..70677c1ae32 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/index.ts @@ -0,0 +1 @@ +export { AiEditor } from './AiEditor' diff --git a/apps/journeys-admin/src/components/Editor/Toolbar/Items/AIEditorItem/AIEditorItem.tsx b/apps/journeys-admin/src/components/Editor/Toolbar/Items/AIEditorItem/AIEditorItem.tsx new file mode 100644 index 00000000000..2904fb8719a --- /dev/null +++ b/apps/journeys-admin/src/components/Editor/Toolbar/Items/AIEditorItem/AIEditorItem.tsx @@ -0,0 +1,40 @@ +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' +import Box from '@mui/material/Box' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import { ComponentProps, ReactElement } from 'react' + +import { Item } from '../Item/Item' + +interface AIEditorItemProps { + variant: ComponentProps['variant'] + closeMenu?: () => void +} + +export function AIEditorItem({ + variant, + closeMenu +}: AIEditorItemProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + const router = useRouter() + const journeyId = router.query.journeyId as string | undefined + + function handleClick(): void { + if (journeyId == null) return + void router.push(`/journeys/${journeyId}/ai-chat`) + closeMenu?.() + } + + return ( + + } + onClick={handleClick} + ButtonProps={{ disabled: journeyId == null }} + MenuItemProps={{ disabled: journeyId == null }} + /> + + ) +} diff --git a/apps/journeys-admin/src/components/Editor/Toolbar/Items/AIEditorItem/index.ts b/apps/journeys-admin/src/components/Editor/Toolbar/Items/AIEditorItem/index.ts new file mode 100644 index 00000000000..33fb099e44d --- /dev/null +++ b/apps/journeys-admin/src/components/Editor/Toolbar/Items/AIEditorItem/index.ts @@ -0,0 +1 @@ +export { AIEditorItem } from './AIEditorItem' diff --git a/apps/journeys-admin/src/components/Editor/Toolbar/Toolbar.tsx b/apps/journeys-admin/src/components/Editor/Toolbar/Toolbar.tsx index e4e62d525fe..09a30d4be67 100644 --- a/apps/journeys-admin/src/components/Editor/Toolbar/Toolbar.tsx +++ b/apps/journeys-admin/src/components/Editor/Toolbar/Toolbar.tsx @@ -43,6 +43,7 @@ import { NotificationPopover } from '../../NotificationPopover' import { EDIT_TOOLBAR_HEIGHT } from '../constants' import { Items } from './Items' +import { AIEditorItem } from './Items/AIEditorItem' import { CommandRedoItem } from './Items/CommandRedoItem' import { CommandUndoItem } from './Items/CommandUndoItem' import { PreviewItem } from './Items/PreviewItem' @@ -345,6 +346,7 @@ export function Toolbar({ user }: ToolbarProps): ReactElement { alignItems="center" > + diff --git a/apps/journeys-admin/src/libs/useJourneyAiChatSubscription/index.ts b/apps/journeys-admin/src/libs/useJourneyAiChatSubscription/index.ts new file mode 100644 index 00000000000..66589b74c71 --- /dev/null +++ b/apps/journeys-admin/src/libs/useJourneyAiChatSubscription/index.ts @@ -0,0 +1,5 @@ +export { + useJourneyAiChatSubscription, + JOURNEY_AI_CHAT_SUBSCRIPTION +} from './useJourneyAiChatSubscription' +export type { JourneyAiChatMessage } from './useJourneyAiChatSubscription' diff --git a/apps/journeys-admin/src/libs/useJourneyAiChatSubscription/useJourneyAiChatSubscription.ts b/apps/journeys-admin/src/libs/useJourneyAiChatSubscription/useJourneyAiChatSubscription.ts new file mode 100644 index 00000000000..f91f2b08353 --- /dev/null +++ b/apps/journeys-admin/src/libs/useJourneyAiChatSubscription/useJourneyAiChatSubscription.ts @@ -0,0 +1,165 @@ +import { gql, useApolloClient, useSubscription } from '@apollo/client' +import { useCallback, useRef, useState } from 'react' + +export interface JourneyAiChatMessage { + type: string + text?: string | null + operations?: string | null + operationId?: string | null + status?: string | null + turnId?: string | null + journeyUpdated?: boolean | null + requiresConfirmation?: boolean | null + name?: string | null + args?: string | null + summary?: string | null + cardId?: string | null + error?: string | null + validation?: string | null +} + +interface JourneyAiChatInput { + journeyId: string + message: string + history: Array<{ role: string; content: string }> + turnId?: string + contextCardId?: string + preferredTier?: 'free' | 'premium' + languageName?: string +} + +interface UseJourneyAiChatSubscriptionReturn { + messages: JourneyAiChatMessage[] + activeCardIds: Set + isActive: boolean + send: (input: JourneyAiChatInput) => void + stop: () => void +} + +export const JOURNEY_AI_CHAT_SUBSCRIPTION = gql` + subscription JourneyAiChatCreateSubscription( + $input: JourneyAiChatInput! + ) { + journeyAiChatCreateSubscription(input: $input) { + type + text + operations + operationId + status + turnId + journeyUpdated + requiresConfirmation + name + args + summary + cardId + error + validation + } + } +` + +function coalesceTextDelta( + messages: JourneyAiChatMessage[], + incoming: JourneyAiChatMessage +): JourneyAiChatMessage[] { + if (incoming.type !== 'text') return [...messages, incoming] + + const last = messages[messages.length - 1] + if (last?.type === 'text') { + const merged: JourneyAiChatMessage = { + ...last, + text: (last.text ?? '') + (incoming.text ?? '') + } + return [...messages.slice(0, -1), merged] + } + + return [...messages, incoming] +} + +export function useJourneyAiChatSubscription(): UseJourneyAiChatSubscriptionReturn { + const client = useApolloClient() + const [subscriptionInput, setSubscriptionInput] = + useState(null) + const [messages, setMessages] = useState([]) + const [activeCardIds, setActiveCardIds] = useState>(new Set()) + const activeCardIdsRef = useRef>(new Set()) + + useSubscription(JOURNEY_AI_CHAT_SUBSCRIPTION, { + variables: { input: subscriptionInput }, + skip: subscriptionInput == null, + onData({ data: { data } }) { + const event = data?.journeyAiChatCreateSubscription + if (event == null) return + + const message: JourneyAiChatMessage = { + type: event.type ?? '', + text: event.text, + operations: event.operations, + operationId: event.operationId, + status: event.status, + turnId: event.turnId, + journeyUpdated: event.journeyUpdated, + requiresConfirmation: event.requiresConfirmation, + name: event.name, + args: event.args, + summary: event.summary, + cardId: event.cardId, + error: event.error, + validation: event.validation + } + + setMessages((prev) => coalesceTextDelta(prev, message)) + + if (message.type === 'plan_progress' && message.cardId != null) { + const next = new Set(activeCardIdsRef.current) + if (message.status === 'done') { + next.delete(message.cardId) + } else { + next.add(message.cardId) + } + activeCardIdsRef.current = next + setActiveCardIds(next) + } + + if ( + message.journeyUpdated === true && + subscriptionInput != null + ) { + void client.refetchQueries({ include: ['GetAdminJourney'] }) + } + + if (message.type === 'done' || message.type === 'error') { + setSubscriptionInput(null) + activeCardIdsRef.current = new Set() + setActiveCardIds(new Set()) + } + }, + onError() { + setSubscriptionInput(null) + activeCardIdsRef.current = new Set() + setActiveCardIds(new Set()) + } + }) + + const send = useCallback((input: JourneyAiChatInput) => { + setMessages([]) + activeCardIdsRef.current = new Set() + setActiveCardIds(new Set()) + setSubscriptionInput(input) + }, []) + + const stop = useCallback(() => { + setSubscriptionInput(null) + activeCardIdsRef.current = new Set() + setActiveCardIds(new Set()) + }, []) + + return { + messages, + activeCardIds, + isActive: subscriptionInput != null, + send, + stop + } +} diff --git a/docs/plans/2026-03-18-001-feat-ai-chat-journey-editor-plan.md b/docs/plans/2026-03-18-001-feat-ai-chat-journey-editor-plan.md new file mode 100644 index 00000000000..7c496b509fe --- /dev/null +++ b/docs/plans/2026-03-18-001-feat-ai-chat-journey-editor-plan.md @@ -0,0 +1,1497 @@ +--- +title: AI Chat Interface for Journey Creation and Editing +type: feat +status: active +date: 2026-03-18 +updated: 2026-03-19 +--- + +# AI Chat Interface for Journey Creation and Editing + +## Overview + +Build a chat-based AI experience inside `journeys-admin` that lets non-technical creators describe what they want — and have their journey built or edited in real time. The AI understands the journey block structure and manipulates it via tools, streaming its thinking back to the user. + +--- + +## Problem Statement + +The existing journey editor is a powerful visual tool, but it requires understanding the block system — steps, cards, typography, buttons, actions, and their relationships. Non-technical creators often struggle to build flows from scratch or make systematic changes across many cards. There is no path from "I want a 5-card onboarding flow" to a working journey without manual block-by-block construction. + +--- + +## Proposed Solution + +### AI Editor (`/journeys/[id]/ai-chat`) + +A new, dedicated editor route alongside the existing manual editor. The AI editor shows a read-only journey flow canvas (full card previews) on the left and an AI chat panel on the right. Users describe what they want; the AI edits the journey while the canvas updates live with per-card loading states. + +### Backend AI Agent + +A new GraphQL SSE subscription `journeyAiChatCreateSubscription` in `api-journeys-modern`. The backend runs a two-phase loop: + +1. **Plan phase** — AI reads journey state (injected into system prompt), produces a plan via `submit_plan` tool +2. **Execute phase** — server executes plan operations directly, streaming live progress back to the client + +### Tiered AI Model + +| Tier | Model | Who pays | +|------|-------|----------| +| Free (default) | Gemini 2.5 Flash | Application (`GOOGLE_GENERATIVE_AI_API_KEY`) | +| Premium | Claude Sonnet 4 | User (BYOK — own Anthropic API key) | + +Users get Gemini Flash for free. Power users can connect their own Anthropic API key for Claude (encrypted server-side via AES-256/GCP KMS). + +### Mid-session model switching + +Users can switch between free (Gemini Flash) and premium (Claude) tiers without losing conversation context: + +- Subscription input includes `preferredTier?: 'free' | 'premium'` (defaults to `'premium'` if API key exists) +- Model selection: `(userKey && preferredTier !== 'free') ? claude : gemini` +- Conversation history is client-managed and format-compatible across models (Vercel AI SDK normalizes messages) +- Switching is instant — the next message simply uses the new tier +- Use cases: Claude rate limit hit → switch to free tier; testing free vs premium quality +- Client stores `preferredTier` in React state (session-scoped, resets to premium on refresh if key exists) + +--- + +## Key Architectural Decisions + +### Why `JourneySimple` is the AI-native layer + +The codebase already has a purpose-built simplified schema at `libs/shared/ai/src/journeySimpleTypes.ts`. Phase 0 expands it to cover all 8 addable block types. After Phase 0, a journey is represented as: + +```typescript +{ + title: string + description: string + cards: Array<{ + id: string // content-derived: "card-welcome", not "card-1" + x?: number // optional — auto-assigned if omitted + y?: number // optional — defaults to 0 + backgroundColor?: string + backgroundImage?: { src, alt, width?, height?, blurhash? } + backgroundVideo?: { url, startAt?, endAt? } + defaultNextCard?: string + content: Array< + | { type: 'heading'; text: string; variant?: 'h1'|'h2'|'h3'|'h4'|'h5'|'h6' } + | { type: 'text'; text: string; variant?: 'body1'|'body2'|'subtitle1'|'subtitle2'|'caption'|'overline' } + | { type: 'button'; text: string; action: Action } + | { type: 'image'; src: string; alt: string; width?: number; height?: number; blurhash?: string } + | { type: 'video'; url: string; startAt?: number; endAt?: number } + | { type: 'poll'; gridView?: boolean; options: Array<{ text: string; action: Action }> } + | { type: 'multiselect'; min?: number; max?: number; options: string[] } + | { type: 'textInput'; label: string; inputType?: 'freeForm'|'name'|'email'|'phone'; placeholder?: string; hint?: string; required?: boolean } + | { type: 'spacer'; spacing: number } + > + }> +} + +// Action — 5 types, discriminated by `kind` +type Action = + | { kind: 'navigate'; cardId: string } + | { kind: 'url'; url: string } + | { kind: 'email'; email: string } + | { kind: 'chat'; chatUrl: string } + | { kind: 'phone'; phone: string; countryCode?: string; contactAction?: 'call'|'text' } +``` + +### Hybrid write strategy + +- `generate_journey` (full atomic replacement via `updateSimpleJourney`) — for creation from scratch, or rewriting >50% of the journey +- Surgical tools (`create_card`, `add_block`, `update_block`, etc.) — for targeted edits to existing journeys + +### Journey state injected into system prompt + +Server calls `getAgentJourney(journeyId)` before the AI call and injects the full journey state (with blockIds + navigationMap) into the system prompt. This is faster than a tool call (no round-trip), 100% reliable (AI always has current state), and costs ~5-10K tokens (acceptable for Claude/Gemini). + +### Two-phase loop: AI plans, server executes + +1. **Plan phase**: AI reads journey state (from system prompt), calls `submit_plan` tool with structured operations list +2. **Execute phase**: Server iterates plan operations and executes each directly (no AI in the loop) — fast, cheap, reliable + +AI only re-enters via conversation if something fails ("retry" or "fix the error"). + +### Server-driven execution rationale + +Both approaches show the same real-time visuals (card shimmer, progress updates, plan card). Server-driven execution completes in ~0.5s total vs ~15s for AI-driven (3-5s per operation). The plan descriptions provide operation-level context without AI narration. + +### Streaming via GraphQL SSE Subscription + +The `journeyAiTranslateCreateSubscription` pattern (`apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyAiTranslate.ts`) already handles streaming over SSE. We adopt the same pattern: a `subscribe` async generator that `yield`s `JourneyAiChatMessage` objects. + +Use `streamText` (not `generateText`) with tools for the plan phase to stream Claude's text word-by-word to the user. + +### Snapshot-based undo (per-turn checkpoints) + +At the start of each agent turn, snapshot the current `JourneySimple` server-side (keyed by `turnId`, TTL 1 hour via Redis). Keys: `undo:${turnId}`, value: JSON-serialized snapshot, TTL: 1 hour via `EX 3600`. This survives pod restarts and works across replicas. Uses the existing Redis connection (`libs/yoga/src/redis/connection.ts`). + +**Undo UX: hover-to-reveal on user message bubble** + +Each user message that triggered AI changes shows a small undo icon (↩) at the bottom-right corner of the message bubble on hover. This keeps undo contextual — tied directly to the request that caused the change, acting as a per-turn checkpoint. + +- **Hover state**: Small gray pill with undo icon appears at bottom-right corner of the user's chat bubble +- **Click**: Opens an inline confirmation below the AI response with: + - Warning icon + "Revert all changes from this turn?" + - Contextual description of what will be undone (e.g. "This will remove the email input from 'How did you hear?' and undo the navigation change") + - "Yes, revert" (destructive red) + "Cancel" buttons +- **Confirmed**: `journeyAiChatUndoMutation(turnId)` restores the pre-turn snapshot via `updateSimpleJourney` +- Each completed turn is an independent checkpoint with its own undo +- Multiple turns = multiple undo points (not just the most recent) +- Undoing a past turn that has subsequent turns shows a warning: "This will also revert all later changes" +- TTL: 1 hour via Redis (`EX 3600`) + +### Conversation history: ephemeral, client-managed + +Chat history lives in React state only — lost on page refresh. Client sends full `history` array with each subscription call. Server is stateless (no stored conversations). + +**History validation:** Server validates each history entry before passing to the AI: +- Only `user` and `assistant` roles accepted (reject `system` role from client) +- Each message content limited to 8000 characters +- Total history limited to 50 entries +- Strip any entries with unexpected structure + +When history exceeds ~20 messages, server auto-summarizes older messages using the SAME model tier the user is on (free tier users get Gemini Flash for summarization, premium users get their Claude key), keeping the last 16 messages in full. This avoids introducing a third model dependency. Bounds context to ~52K tokens max. + +### Risk-based confirmation (not count-based) + +- `generate_journey` (full replacement) → **always confirm** +- `delete_card` × >3 → **confirm** +- Everything else (add, update, reorder) → **never confirm**, even 20+ operations + +**Server-derived confirmation:** The `requiresConfirmation` flag is computed server-side from the operations list, not set by the AI: +```typescript +const requiresConfirmation = plan.operations.some(o => o.tool === 'generate_journey') + || plan.operations.filter(o => o.tool === 'delete_card').length > 3 +``` +`requiresConfirmation` is not a parameter of the `submit_plan` tool. + +--- + +## Architecture Diagram + +``` +┌─── journeys-admin (Next.js) ───────────────────────────────────────────┐ +│ │ +│ /journeys/[id] (existing manual editor — unchanged) │ +│ /journeys/[id]/ai-chat (NEW AI editor) │ +│ │ +│ ┌── AiEditor ────────────────────────────────────────────────────┐ │ +│ │ AiEditorToolbar [← Edit Manually] [Title] [■ Stop] │ │ +│ │ │ │ +│ │ ┌─ AiJourneyFlow (60%) ─────┐ ┌─ AiChatPanel (40%) ───────┐ │ │ +│ │ │ React Flow — view-only │ │ Message history │ │ │ +│ │ │ Full card previews at │ │ Starter suggestion chips │ │ │ +│ │ │ 0.55 scale with scroll │ │ AI: I'll add a poll... │ │ │ +│ │ │ Labeled edges │ │ PlanCard with progress │ │ │ +│ │ │ Cards shimmer + spinner │ │ [Undo all] │ │ │ +│ │ │ Auto-pans to active card │ │ [Your message...] [Send] │ │ │ +│ │ └───────────────────────────┘ └────────────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ GraphQL SSE subscription + ▼ +┌─── api-journeys-modern (GraphQL Yoga) ──────────────────────────────────┐ +│ │ +│ journeyAiChatCreateSubscription(journeyId, message, history, turnId?, │ +│ contextCardId?, preferredTier?) │ +│ │ │ +│ ▼ │ +│ ┌── Two-phase Loop ─────────────────────────────────────────────┐ │ +│ │ model: user.anthropicKey ? claude-sonnet-4-6 : gemini-2.5-flash│ │ +│ │ │ │ +│ │ PHASE 1 (Plan — AI call with streamText): │ │ +│ │ Journey state injected into system prompt │ │ +│ │ AI streams text + calls submit_plan tool │ │ +│ │ yield { type: 'plan', operations[], requiresConfirmation } │ │ +│ │ │ │ +│ │ PHASE 2 (Execute — server-driven, no AI): │ │ +│ │ For each operation: │ │ +│ │ yield { type: 'plan_progress', operationId, 'running' } │ │ +│ │ Execute operation via Prisma │ │ +│ │ yield { type: 'plan_progress', operationId, 'done'/'failed'}│ │ +│ │ Post-execution: validate_journey, include result in done msg │ │ +│ │ │ │ +│ │ AI Tools: search_images, submit_plan │ │ +│ │ Server ops: generate_journey, create_card, delete_card, │ │ +│ │ update_card, add_block, update_block, delete_block, │ │ +│ │ reorder_cards, update_journey_settings, translate │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Implementation Phases + +### Phase 0: Redesign `JourneySimple` for Full Block Coverage + +**Goal:** `JourneySimple` currently covers about half the block system. An AI agent asked to "add a text input" or "add a multiselect" will silently fail because those block types don't exist in the schema. This phase redesigns the schema to cover every addable block type, fix structural problems that cause hallucinations, and make navigation errors impossible to miss. + +**Files to modify:** +- `libs/shared/ai/src/journeySimpleTypes.ts` — full rewrite +- `apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.ts` — full rewrite +- `apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.ts` — full rewrite +- `apis/api-journeys-modern/src/schema/journey/simple/getSimpleJourney.spec.ts` +- `apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.spec.ts` +- `apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.spec.ts` +- `libs/shared/ai/src/journeySimpleTypes.spec.ts` + +--- + +#### Current coverage gaps + +The 8 block types a creator can add via the editor's AddBlock panel vs what `JourneySimple` supports today: + +| Block | Addable in editor | In JourneySimple | +|---|---|---| +| TypographyBlock | ✅ | ⚠️ h3 + body1 only — 10 other variants ignored | +| ButtonBlock | ✅ | ⚠️ label + navigate/url only — Email/Chat/Phone actions missing | +| ImageBlock | ✅ | ⚠️ present but width/height/blurhash required (hallucination pressure) | +| VideoBlock | ✅ | ⚠️ YouTube only — Mux/Cloudflare sources ignored | +| RadioQuestionBlock | ✅ | ⚠️ present but gridView and poll option images missing | +| MultiselectBlock | ✅ | ❌ missing entirely | +| TextResponseBlock | ✅ | ❌ missing entirely | +| SpacerBlock | ✅ | ❌ missing entirely | + +Additionally, the flat-field structure (`heading`, `text`, `button`) prevents multiple instances of the same block type on one card and loses ordering information. + +Note: `SignUpBlock` has a directory in AddBlock but is **not rendered** in the AddBlock panel. Excluded from scope. + +--- + +#### New schema: `content` array with discriminated union + +Replace the flat card fields with an ordered `content: JourneySimpleBlock[]` array. Each element has a `type` discriminator. + +```typescript +// libs/shared/ai/src/journeySimpleTypes.ts + +// --- Actions --- +export const journeySimpleActionSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('navigate'), + cardId: z.string().describe('The id of the card to navigate to.') + }), + z.object({ + kind: z.literal('url'), + url: z.string().url().refine( + u => u.startsWith('https://') || u.startsWith('http://'), + 'Only https:// and http:// URLs are allowed' + ).describe('A URL to open in the browser.') + }), + z.object({ + kind: z.literal('email'), + email: z.string().email().describe('An email address to open in the mail client.') + }), + z.object({ + kind: z.literal('chat'), + chatUrl: z.string().url().refine( + u => u.startsWith('https://') || u.startsWith('http://'), + 'Only https:// and http:// URLs are allowed' + ).describe('A WhatsApp or chat URL.') + }), + z.object({ + kind: z.literal('phone'), + phone: z.string(), + countryCode: z.string().optional(), + contactAction: z.enum(['call', 'text']).optional() + }) +]) + +// --- Content Blocks (discriminated union) --- +export const journeySimpleBlockSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('heading'), + text: z.string(), + variant: z.enum(['h1','h2','h3','h4','h5','h6']).optional().default('h3') + }), + z.object({ + type: z.literal('text'), + text: z.string(), + variant: z.enum(['body1','body2','subtitle1','subtitle2','caption','overline']).optional().default('body1') + }), + z.object({ + type: z.literal('button'), + text: z.string().describe('Label displayed on the button.'), + action: journeySimpleActionSchema + }), + z.object({ + type: z.literal('image'), + src: z.string(), + alt: z.string(), + width: z.number().int().nonnegative().optional().describe('Omit — server computes from URL.'), + height: z.number().int().nonnegative().optional().describe('Omit — server computes from URL.'), + blurhash: z.string().optional().describe('Omit — server computes from URL.') + }), + z.object({ + type: z.literal('video'), + url: z.string().describe('YouTube URL.'), + startAt: z.number().int().nonnegative().optional(), + endAt: z.number().int().positive().optional() + }), + z.object({ + type: z.literal('poll'), + gridView: z.boolean().optional().default(false), + options: z.array(z.object({ + text: z.string(), + action: journeySimpleActionSchema + })).min(2) + }), + z.object({ + type: z.literal('multiselect'), + min: z.number().int().nonnegative().optional(), + max: z.number().int().positive().optional(), + options: z.array(z.string()).min(2) + }), + z.object({ + type: z.literal('textInput'), + label: z.string(), + inputType: z.enum(['freeForm','name','email','phone']).optional().default('freeForm'), + placeholder: z.string().optional(), + hint: z.string().optional(), + required: z.boolean().optional().default(false) + }), + z.object({ + type: z.literal('spacer'), + spacing: z.number().int().positive().describe('Vertical spacing in pixels.') + }) +]) + +// --- Card --- +export const journeySimpleCardSchema = z.object({ + id: z.string().describe( + 'Unique, semantic identifier. Use descriptive names like "card-welcome" or "card-results". Do NOT use positional names like "card-1".' + ), + x: z.number().optional().describe('Canvas x position. Omit to auto-layout (index * 300).'), + y: z.number().optional().describe('Canvas y position. Omit to default to 0.'), + backgroundColor: z.string().optional().describe('Hex color for the card background e.g. "#1A1A2E".'), + backgroundImage: journeySimpleImageSchema.optional(), + backgroundVideo: z.object({ + url: z.string().describe('YouTube URL.'), + startAt: z.number().int().nonnegative().optional(), + endAt: z.number().int().positive().optional() + }).optional(), + content: z.array(journeySimpleBlockSchema).describe('Ordered array of content blocks on this card.'), + defaultNextCard: z.string().optional() +}) + +// --- Journey --- +export const journeySimpleSchema = z.object({ + title: z.string(), + description: z.string(), + cards: z.array(journeySimpleCardSchema) +}) + +// Write schema adds cross-card reference validation + video/background rules +export const journeySimpleSchemaUpdate = journeySimpleSchema.superRefine((data, ctx) => { + const cardIds = new Set(data.cards.map((c) => c.id)) + for (const card of data.cards) { + // Cross-card reference validation + const checkRef = (ref: string | undefined, path: string) => { + if (ref && !cardIds.has(ref)) { + ctx.addIssue({ + code: 'custom', + path: ['cards', data.cards.indexOf(card), path], + message: `Card "${ref}" does not exist. Valid IDs: ${[...cardIds].join(', ')}` + }) + } + } + checkRef(card.defaultNextCard, 'defaultNextCard') + for (const block of card.content) { + if (block.type === 'button' && block.action.kind === 'navigate') + checkRef(block.action.cardId, 'content[button].action.cardId') + if (block.type === 'poll') + block.options.forEach((o, i) => { + if (o.action.kind === 'navigate') + checkRef(o.action.cardId, `content[poll].options[${i}].action.cardId`) + }) + } + + // Video/background rules + const hasContentVideo = card.content.some(b => b.type === 'video') + const hasBgImage = card.backgroundImage != null + const hasBgVideo = card.backgroundVideo != null + + if (hasContentVideo && card.content.length > 1) + ctx.addIssue({ code: 'custom', message: 'Video content must be the only content block on a card' }) + if (hasContentVideo && (hasBgImage || hasBgVideo)) + ctx.addIssue({ code: 'custom', message: 'Video content cards cannot have background image/video' }) + if (hasBgImage && hasContentVideo) + ctx.addIssue({ code: 'custom', message: 'Cards with background image cannot have video content' }) + if (hasBgImage && hasBgVideo) + ctx.addIssue({ code: 'custom', message: 'Card cannot have both backgroundImage and backgroundVideo' }) + } +}) +``` + +--- + +#### Content-derived card IDs for existing journeys + +`simplifyJourney` generates card IDs from card heading/text content (not positional): + +```typescript +function generateCardIds(cards: { heading?: string; firstText?: string }[]): string[] { + const usedIds = new Set() + return cards.map((card) => { + const label = card.heading || card.firstText || 'untitled' + const slug = label.toLowerCase().replace(/[^a-z0-9\s]/g, '').trim() + .split(/\s+/).slice(0, 4).join('-') || 'untitled' + let id = `card-${slug}` + let counter = 2 + while (usedIds.has(id)) { id = `card-${slug}-${counter++}` } + usedIds.add(id) + return id + }) +} +``` + +Handles: no heading (fallback to text → "untitled"), duplicates (suffix -2, -3), special chars (stripped), long headings (first 4 words). + +**ID stability for existing cards**: When `simplifyJourney` runs on an existing journey, card IDs are generated from content. However, surgical tools (`update_block`) that change headings must NOT regenerate IDs — they use the stable `blockId` from Prisma. Card `simpleId`s are only generated during `simplifyJourney` (read path) and `updateSimpleJourney` (full replacement). Surgical edits never touch `simpleId`s because they operate on `blockId`s directly. + +--- + +#### Changes to `simplifyJourney.ts` + +Rewrite to emit the new `content` array format. Key mappings: + +| Prisma block `typename` | `JourneySimpleBlock.type` | Notes | +|---|---|---| +| `TypographyBlock` with `variant: h1-h6` | `heading` | Preserve variant | +| `TypographyBlock` with other variants | `text` | Preserve variant | +| `ButtonBlock` | `button` | Map all 5 action types to `kind` | +| `ImageBlock` (non-cover) | `image` | | +| `VideoBlock` (youTube, non-cover) | `video` | Only YouTube for now | +| `RadioQuestionBlock` + children | `poll` | Assemble options with actions | +| `MultiselectBlock` + children | `multiselect` | Options are `MultiselectOptionBlock.label` (no actions) | +| `TextResponseBlock` | `textInput` | Map Prisma `type` → schema `inputType` | +| `SpacerBlock` | `spacer` | | +| `CardBlock.coverBlockId` → `ImageBlock` | `card.backgroundImage` | Card-level field | +| `CardBlock.coverBlockId` → `VideoBlock` (youTube) | `card.backgroundVideo` | Card-level field | + +Sort childBlocks by `parentOrder` (default null to Infinity). Reverse action mapping: + +```typescript +function mapActionReverse(action: Action | null): JourneySimpleAction | undefined { + if (!action) return undefined + if (action.blockId) return { kind: 'navigate', cardId: resolveCardId(action.blockId) } + if (action.url) return { kind: 'url', url: action.url } + if (action.email) return { kind: 'email', email: action.email } + if (action.chatUrl) return { kind: 'chat', chatUrl: action.chatUrl } + if (action.phone) return { kind: 'phone', phone: action.phone, countryCode: action.countryCode, contactAction: action.contactAction } + return undefined +} +``` + +--- + +#### Changes to `updateSimpleJourney.ts` + +Move all external I/O before the transaction: + +```typescript +// 1. [OUTSIDE TX] Pre-process all images (Cloudflare upload + blurhash) +// processImage: URL validation → conditional Cloudflare upload → blurhash generation +const processedImages = await preProcessAllImages(simple.cards) + +// 2. [OUTSIDE TX] Pre-process all videos (YouTube API duration fetch) +const videoMetadata = await preProcessAllVideos(simple.cards) + +// 3. Transaction: only DB writes +return prisma.$transaction(async (tx) => { + // a. Soft-delete all existing blocks + // b. Update journey title/description + // c. For each card: + // - Create StepBlock (parentOrder = card index, x ?? index * 300, y ?? 0) + // - Create CardBlock (parentBlockId = stepBlock.id, parentOrder = 0) + // - For each content block (parentOrder = index): + // switch (block.type): + // 'heading'/'text' → TypographyBlock (variant from block) + // 'button' → ButtonBlock + Action (map all 5 action kinds) + // 'image' → ImageBlock (from pre-processed map) + // 'video' → VideoBlock (YouTube only, from pre-processed duration) + // 'poll' → RadioQuestionBlock + RadioOptionBlock children (each with Action) + // 'multiselect' → MultiselectBlock + MultiselectOptionBlock children (label only, no actions) + // 'textInput' → TextResponseBlock (map inputType → Prisma type field) + // 'spacer' → SpacerBlock (spacing field) + // - If backgroundImage: create ImageBlock + set CardBlock.coverBlockId + // - If backgroundVideo: create VideoBlock (YouTube, autoplay:true) + set CardBlock.coverBlockId + // - Set StepBlock.nextBlockId for defaultNextCard +}) +``` + +Action mapping helper (all 5 kinds): + +```typescript +function mapAction(action: JourneySimpleAction, stepBlocks: StepBlockMap) { + switch (action.kind) { + case 'navigate': return { blockId: stepBlocks.find(s => s.simpleCardId === action.cardId)?.stepBlockId } + case 'url': return { url: action.url } + case 'email': return { email: action.email } + case 'chat': return { chatUrl: action.chatUrl } + case 'phone': return { phone: action.phone, countryCode: action.countryCode, contactAction: action.contactAction } + } +} +``` + +--- + +#### Phase 0 acceptance criteria + +- [ ] All 8 addable block types are representable in `JourneySimple` +- [ ] All 5 action types (navigate, url, email, chat, phone) work in buttons and poll options +- [ ] All TypographyBlock variants (h1–h6, body1/2, subtitle1/2, caption, overline) round-trip correctly +- [ ] `backgroundImage` and `backgroundVideo` both work (mutually exclusive via Zod) +- [ ] Video card exclusivity: Zod rejects content arrays with video + other blocks +- [ ] Content-derived card IDs: heading "Welcome!" → `card-welcome` +- [ ] `x`/`y` can be omitted — server defaults to `index * 300, 0` +- [ ] `image.width`, `image.height`, `image.blurhash` can be omitted — server computes from URL +- [ ] A `nextCard`/`cardId` referencing a non-existent card produces a Zod error naming the invalid ID and listing valid IDs +- [ ] MultiselectBlock creates parent + MultiselectOptionBlock children (label only, no actions) +- [ ] TextResponseBlock `inputType` maps to Prisma `type` field correctly +- [ ] `simplifyJourney` produces a `JourneySimple` that `updateSimpleJourney` can ingest without data loss +- [ ] All existing tests updated and passing +- [ ] `nx type-check shared-ai && nx type-check api-journeys-modern` passes +- [ ] `nx build api-journeys-modern` passes + +--- + +### Phase 1: Backend AI Agent + +**Goal:** Working `journeyAiChatCreateSubscription` with a complete tool set, two-phase loop (AI plans, server executes), backend safety guards, and tiered model support. + +**Files to create/modify:** + +- `apis/api-journeys-modern/src/schema/journeyAiChat/journeyAiChat.ts` — subscription + agent loop +- `apis/api-journeys-modern/src/schema/journeyAiChat/getAgentJourney.ts` — read function with blockIds + navigationMap + journey language name +- `apis/api-journeys-modern/src/schema/journeyAiChat/executeOperation.ts` — server-side plan executor +- `apis/api-journeys-modern/src/schema/journeyAiChat/tools/` — one file per tool +- `apis/api-journeys-modern/src/schema/journeyAiChat/systemPrompt.ts` +- `apis/api-journeys-modern/src/schema/journeyAiChat/snapshotStore.ts` — Redis-based snapshot storage with TTL for undo +- `apis/api-journeys-modern/src/schema/journeyAiChat/journeyAiChat.spec.ts` +- `libs/shared/ai/src/agentJourneyTypes.ts` — AgentJourney, PlanOperation, NavigationMap types + +#### `AgentJourney` type definition + +```typescript +// libs/shared/ai/src/agentJourneyTypes.ts + +interface AgentJourney { + title: string + description: string + language: string // Journey's language name (e.g. "English", "Spanish") + cards: Array<{ + simpleId: string // Content-derived ID (e.g. "card-welcome") + blockId: string // Prisma StepBlock.id — stable across edits, used by surgical tools + cardBlockId: string // Prisma CardBlock.id + heading?: string // First heading text (for display) + content: Array // Each content block includes its Prisma block ID for surgical targeting + backgroundColor?: string + backgroundImage?: { src: string; alt: string } + backgroundVideo?: { url: string } + defaultNextCard?: string // simpleId of next card + }> + navigationMap: Array<{ + sourceCardId: string // simpleId + targetCardId: string // simpleId + trigger: 'default' | 'button' | 'poll' + label?: string // Button/poll option text + blockId: string // The block that owns this navigation + }> +} +``` + +The AI references `blockId` for surgical operations (update_block, delete_block), ensuring stable references that don't change when content changes. The `simpleId` is for human-readable references in descriptions. + +- `apis/api-journeys-modern/src/env.ts` — add `ANTHROPIC_API_KEY` (optional, for server fallback) +- `package.json` — add `@ai-sdk/anthropic` + +--- + +#### Agent loop implementation + +```typescript +subscribe: async function* (_root, { input }, context) { + // Auth (same pattern as journeyAiTranslate) + const journey = await prisma.journey.findUnique(...) + if (!journey) throw new GraphQLError('journey not found') + if (!ability(Action.Update, subject('Journey', journey), context.user)) + throw new GraphQLError('permission denied') + + // Published journey warning + if (journey.status === 'published') { + yield { type: 'warning', text: 'This journey is published and live. Changes will be visible to users immediately.' } + } + + const abort = new AbortController() + context.request.signal.addEventListener('abort', () => abort.abort()) + + // Snapshot for undo + const turnId = input.turnId ?? randomUUID() + const snapshot = await getSimpleJourney(input.journeyId) + await redis.set(`journeyAiChat:undo:${turnId}`, JSON.stringify({ + snapshot, + userId: context.user.id, + journeyId: input.journeyId, + plan: null + }), 'EX', 3600) + + // Tiered model selection (supports mid-session switching) + const userKey = await decryptAnthropicKey(context.user.id) + const preferFree = input.preferredTier === 'free' + const model = (userKey && !preferFree) + ? anthropic('claude-sonnet-4-6', { apiKey: userKey }) + : google('gemini-2.5-flash') + + // Inject journey state into system prompt + const agentJourney = await getAgentJourney(input.journeyId) + // Validate contextCardId against actual journey state + const contextCard = input.contextCardId + ? agentJourney.cards.find(c => c.simpleId === input.contextCardId) + : null + const contextSuffix = contextCard + ? `\n\nThe user is referring to: "${contextCard.heading ?? 'Untitled'}" card (blockId: ${contextCard.blockId}) — contains ${contextCard.content.length} blocks.` + : '' + const fullSystemPrompt = `${preSystemPrompt}\n\n${systemPrompt}\n\n## Current Journey State\n${hardenPrompt(JSON.stringify(agentJourney))}\n\nSummary: ${agentJourney.cards.length} cards, ${countBlocks(agentJourney)} blocks.${contextSuffix}` + + // Condense history if needed (>20 messages) + const condensedHistory = await condenseHistory(input.history) + + // Phase 1: Plan (AI call with streamText) + const result = streamText({ + model, + system: fullSystemPrompt, + messages: [...condensedHistory, { role: 'user', content: input.message }], + tools: { + search_images: { ... }, + // validate_journey runs automatically server-side after execution (not an AI tool) + submit_plan: { + description: 'Submit your execution plan', + parameters: planOperationArraySchema, + execute: async (args) => args + } + }, + maxSteps: 5, + abortSignal: abort.signal + }) + + // Stream text + tool events to client + let plan = null + for await (const event of result.fullStream) { + switch (event.type) { + case 'text-delta': + yield { type: 'text', text: event.textDelta } + break + case 'tool-call': + if (event.toolName === 'submit_plan') plan = event.args + yield { type: 'tool_call', name: event.toolName, args: JSON.stringify(event.args) } + break + case 'tool-result': + yield { type: 'tool_result', name: event.toolName, summary: summarize(event.result) } + break + } + } + + if (!plan) { + // AI responded conversationally without planning — that's fine + yield { type: 'done', journeyUpdated: false, turnId } + return + } + + // Server-derived confirmation (not AI-set) + const requiresConfirmation = plan.operations.some(o => o.tool === 'generate_journey') + || plan.operations.filter(o => o.tool === 'delete_card').length > 3 + yield { type: 'plan', operations: plan.operations, turnId, requiresConfirmation } + + if (requiresConfirmation) { + // Store plan for later execution via executePlanId + snapshotStore.set(turnId, { ...snapshotStore.get(turnId), plan: plan.operations }) + yield { type: 'done', journeyUpdated: false, turnId } + return + } + + // Phase 2: Server-driven execution + let journeyUpdated = false + for (const op of plan.operations) { + if (abort.signal.aborted) break + yield { type: 'plan_progress', operationId: op.id, status: 'running', cardId: op.cardId } + try { + await executeOperation(op, input.journeyId, context) + yield { type: 'plan_progress', operationId: op.id, status: 'done' } + journeyUpdated = true + } catch (error) { + const safeMessage = sanitizeErrorMessage((error as Error).message) + yield { type: 'plan_progress', operationId: op.id, status: 'failed', error: safeMessage } + // Log full error server-side + logger.error('Operation failed', { operationId: op.id, error }) + yield { type: 'text', text: `Failed: ${safeMessage}. You can undo all changes or ask me to retry.` } + break + } + } + + // Post-execution validation + const validation = journeyUpdated ? await validateJourney(input.journeyId) : null + + yield { type: 'done', journeyUpdated, turnId, validation } +} +``` + +`sanitizeErrorMessage` strips Prisma table names, connection strings, and stack traces, returning only the user-relevant portion. + +--- + +#### `executeOperation` — server-side plan executor + +```typescript +async function executeOperation(op: PlanOperation, journeyId: string, context) { + switch (op.tool) { + case 'generate_journey': + return updateSimpleJourney(journeyId, op.args.simple) + case 'create_card': + return createCard(journeyId, op.args) + case 'delete_card': + return deleteCard(journeyId, op.args) + case 'update_card': + return updateCard(journeyId, op.args) + case 'add_block': + return addBlock(journeyId, op.args) + case 'update_block': + return updateBlock(journeyId, op.args) + case 'delete_block': + return deleteBlock(journeyId, op.args) + case 'reorder_cards': + return reorderCards(journeyId, op.args) + case 'update_journey_settings': + return updateJourneySettings(journeyId, op.args) + case 'translate': + return translateJourney(journeyId, op.args) + } +} +``` + +#### Surgical tool specifications + +**`create_card`**: Creates StepBlock + CardBlock. If `insertAfterCard` is specified, rewires the source card's `nextBlockId` to the new card, and sets the new card's `nextBlockId` to what was previously the source's next. Assigns `parentOrder` = insert position, shifts subsequent cards' parentOrder +1. + +**`delete_card`**: Soft-deletes StepBlock + CardBlock + all child blocks (`deletedAt = now()`). If `redirectTo` is specified, finds all blocks navigating to the deleted card and rewires them to the redirect target. If no redirect, removes the navigation actions pointing to this card. + +**`update_card`**: Updates CardBlock properties (backgroundColor, backgroundImage, backgroundVideo) and/or StepBlock properties (x, y, nextBlockId for defaultNextCard). Does NOT touch content blocks — use `update_block` for that. + +**`add_block`**: Creates a new block under the specified card's CardBlock. Sets `parentOrder` = insert position (default: end). Shifts subsequent blocks' parentOrder +1. For images: pre-processes via Cloudflare upload + blurhash BEFORE the write (same pattern as `updateSimpleJourney`). For polls: creates parent RadioQuestionBlock + child RadioOptionBlocks. + +**`update_block`**: Updates properties of an existing block by `blockId`. For TypographyBlock: content, variant. For ButtonBlock: label, action. For RadioOptionBlock: label, action. For ImageBlock: src (triggers re-upload + blurhash). For TextResponseBlock: label, placeholder, hint, type. + +**`delete_block`**: Soft-deletes a block by `blockId` (`deletedAt = now()`). Shifts subsequent siblings' parentOrder -1. If the block had navigation actions, those edges are removed. + +**`reorder_cards`**: Takes an ordered array of `blockId`s. Updates each StepBlock's `parentOrder` to match the new order. Updates `x` positions based on new order (`index * 300`). + +**`update_journey_settings`**: Updates Journey-level fields: title, description. + +All surgical tools validate that the target `blockId` exists and belongs to the specified journey before operating. + +--- + +#### `PlanOperation` schema (discriminated union by tool) + +```typescript +const planOperationSchema = z.discriminatedUnion('tool', [ + z.object({ id: z.string(), description: z.string(), tool: z.literal('generate_journey'), cardId: z.string().optional(), args: z.object({ simple: journeySimpleSchema }) }), + z.object({ id: z.string(), description: z.string(), tool: z.literal('create_card'), cardId: z.string().optional(), args: z.object({ card: journeySimpleCardSchema, insertAfterCard: z.string().optional() }) }), + z.object({ id: z.string(), description: z.string(), tool: z.literal('delete_card'), cardId: z.string().optional(), args: z.object({ cardId: z.string(), redirectTo: z.string().optional() }) }), + z.object({ id: z.string(), description: z.string(), tool: z.literal('translate'), cardId: z.string().optional(), args: z.object({ targetLanguage: z.string(), cardIds: z.array(z.string()).optional() }) }), + // ... etc for each tool +]) +``` + +--- + +#### System prompt highlights + +- Role: "You are an AI journey editor for non-technical creators." +- Always Plan before Execute — never skip the planning phase +- Use `generate_journey` for creation/large rewrites; surgical tools for targeted edits +- Always call `search_images` before adding any image — never invent URLs +- `validate_journey` runs automatically server-side after execution completes (not as an AI tool). Results are included in the `done` message. +- Card IDs must be semantic: `card-welcome`, not `card-1` +- blockIds (from system prompt journey state) are stable — use them for update/delete +- If the journey has 0 cards, always use `generate_journey` — never use `create_card` for building from scratch +- When creating new cards via `create_card`, ensure the card ID doesn't collide with existing IDs +- Every non-video card needs at least one navigation path +- Max 20 cards per journey +- Include a complete 2-3 card example journey in the prompt +- Include the journey's language in the system prompt: "This journey's content is in {language}. Respond and generate content in the same language unless the user communicates differently." +- Conciseness: simple edits (1-3 ops) = 1-2 sentences. Complex edits = brief explanation. +- Operation `description` fields must be written for non-technical users. Reference cards by their heading text (e.g. "the Welcome card"), not by ID. Describe what changes in plain English. Never expose block type names, field names, or card IDs. +- If the journey is published, warn the user in the first response that changes are live + +--- + +#### Backend safety + +**1. Snapshot per turn for undo** — Redis-based storage with TTL 1 hour (keys: `undo:${turnId}`, value: JSON-serialized snapshot, `EX 3600`). Uses the existing Redis connection (`libs/yoga/src/redis/connection.ts`). For confirmation flow: stores snapshot + plan together. TTL: 30 minutes. Undo mutation: + +```typescript +builder.mutationField('journeyAiChatUndo', (t) => + t.withAuth({ isAuthenticated: true }).field({ + type: 'Boolean', + args: { + turnId: t.arg.string({ required: true }), + journeyId: t.arg.string({ required: true }) + }, + resolve: async (_root, { turnId, journeyId }, context) => { + // Auth check + const journey = await prisma.journey.findUnique({ where: { id: journeyId } }) + if (!journey) throw new GraphQLError('journey not found') + if (!ability(Action.Update, subject('Journey', journey), context.user)) + throw new GraphQLError('permission denied') + + const entry = snapshotStore.get(turnId) + if (!entry) throw new GraphQLError('Snapshot expired') + + // Verify snapshot belongs to this journey + if (entry.journeyId !== journeyId) + throw new GraphQLError('Snapshot does not match journey') + + await updateSimpleJourney(journeyId, entry.snapshot) + snapshotStore.delete(turnId) + return true + } + }) +) +``` + +**Confirmation flow — `journeyAiChatExecutePlan` mutation**: + +When `requiresConfirmation` is true, the client shows Execute/Cancel. Execute calls this mutation. Cancel calls `journeyAiChatUndo`. + +```typescript +builder.mutationField('journeyAiChatExecutePlan', (t) => + t.withAuth({ isAuthenticated: true }).field({ + type: 'Boolean', + args: { turnId: t.arg.string({ required: true }), journeyId: t.arg.string({ required: true }) }, + resolve: async (_root, { turnId, journeyId }, context) => { + // Auth check + const journey = await prisma.journey.findUnique({ where: { id: journeyId } }) + if (!journey) throw new GraphQLError('journey not found') + if (!ability(Action.Update, subject('Journey', journey), context.user)) + throw new GraphQLError('permission denied') + + const entry = snapshotStore.get(turnId) + if (!entry?.plan) throw new GraphQLError('Plan expired or not found') + for (const op of entry.plan) { + await executeOperation(op, journeyId, context) + } + snapshotStore.set(turnId, { ...entry, plan: undefined }) // Clear plan, keep snapshot for undo + return true + } + }) +) +``` + +**2. Abort on SSE disconnect** — `context.request.signal` → `AbortController` → `abortSignal` in AI SDK calls + checked before each operation in execution loop. + +**3. Optimistic lock via `updatedAt` comparison** — The `expectedUpdatedAt` is captured once before the execution loop begins. If a non-plan write occurred between snapshot creation and first operation, the lock catches it. Once execution starts, the plan owns the journey — its own writes naturally update `updatedAt`. The lock is plan-scoped, not operation-scoped. Implementation: add a `WHERE updatedAt = expectedUpdatedAt` clause to the first Prisma update, catching `RecordNotFound` errors as conflict signals. This uses the existing `updatedAt` field — no schema changes needed. + +**4. I/O outside transaction** — Image upload, YouTube API calls happen before `prisma.$transaction`. + +**5. Conversation history summarization** — When history > 20 messages, server summarizes older messages using the same model tier the user is on (Gemini Flash for free tier, user's Claude key for premium). Keeps last 16 in full. + +--- + +#### User API key management (BYOK Claude) + +```prisma +model UserApiKey { + userId String @id + anthropicApiKeyEncrypted String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) +} +``` + +- Key encrypted with AES-256-GCM via GCP KMS envelope encryption +- Stored in Prisma, never exposed to browser JavaScript (immune to XSS) +- Decrypted server-side for each AI call +- User enters key in AI editor settings modal, validated with test API call + +--- + +### Phase 2: AI Editor Frontend + +**Goal:** New `/journeys/[id]/ai-chat` route with full card preview canvas and live agent activity states. + +#### Route + +``` +apps/journeys-admin/pages/journeys/[journeyId]/ai-chat.tsx +``` + +Needs `getServerSideProps` for auth + journey data fetch (adapt from `pages/journeys/[journeyId].tsx`). + +Navigated to from: +- "✨ AI Editor" button in the existing editor's Toolbar (next to PreviewItem) +- "Create with AI" on the journey list (Phase 3) + +#### Layout + +``` +pages/journeys/[journeyId]/ai-chat.tsx + └─ JourneyProvider (journey data, variant: 'admin') + └─ EditorProvider (steps, selectedStep — reused for canvas data) + └─ CommandProvider (undo/redo) + └─ AiEditor.tsx + ├─ AiEditorToolbar + │ ├─ BackButton ("← Edit Manually" → /journeys/[id]) + │ ├─ JourneyTitle + │ └─ StopButton (visible when AI is active) + └─ Box (display: flex) + ├─ AiJourneyFlow (flex: 3) + │ └─ ReactFlow + │ nodeTypes: { StepBlock: AiCardPreviewNode, ... } + │ edgeTypes: { Custom: AiViewEdge, Start: StartEdge } + │ nodesDraggable={false}, nodesConnectable={false}, fitView + └─ AiChatPanel (flex: 2) + ├─ MessageList (auto-scroll) + │ ├─ MessageBubble (user/AI text) + │ ├─ ToolCallCard (tool progress) + │ ├─ PlanCard (operations + status + Undo) + │ └─ StarterSuggestions (chips, shown when empty) + ├─ ModelIndicator ("AI-powered · Use your own Claude key →") + └─ ChatInput (textarea + Send/Stop toggle) +``` + +#### AiCardPreviewNode — full card preview with scrolling + +```typescript +const CARD_PREVIEW_SCALE = 0.55 +const NODE_WIDTH = Math.round(CARD_WIDTH * CARD_PREVIEW_SCALE) // ≈ 178px + +function AiCardPreviewNode({ data }) { + return ( + + {/* Scrollable wrapper — captures wheel before React Flow */} + e.stopPropagation()} + > + {/* Scaled content in provider context — blocks not clickable */} + + + + + + + + + + {/* AI editing overlay */} + {data.isBeingEdited && ( + <> + + + + + + )} + + ) +} +``` + +BlockRenderer needs `ThemeProvider` + `JourneyProvider` wrapping inside React Flow nodes. Use `variant: 'embed'` to suppress editor-mode behaviors. + +#### Edge labels and visual distinction + +Edges in the AI editor canvas use three visually distinct styles based on connection type: + +| Edge type | Source position | Line style | Color | Label format | +|---|---|---|---|---| +| **Default next** | Card right edge, vertical center | Solid | `#6D6D7D80` (gray, 50%) | Arrow icon + "Default" | +| **Button action** | Card right edge, vertical center | Solid | `#6D6D7D30` (light gray) | Click icon + button label text | +| **Poll/radio option** | Card right edge, spaced vertically per option | Solid | `#4c9bf880` (blue-tinted) | Branch icon + option label text | + +All edges include: +- **Source handle**: 8px circle with white fill and colored border, flush with card right edge +- **Target handle**: Single centered handle on card left edge (all incoming edges converge) +- **Arrow marker**: Small triangle at the target end +- **Label chip**: White pill with 1px border, floating above the line midpoint — contains an icon + text + +For poll/radio options, each option gets its own source handle at a different Y position on the card's right edge (evenly distributed), ensuring edges don't overlap when fanning out to different target cards. + +`AiViewEdge` renders the label chip via React Flow's `EdgeLabelRenderer`. The `transformSteps` utility receives `options.labelEdges = true` to populate `edge.data.label` and `edge.data.edgeType` ('default' | 'button' | 'poll') for styling. + +File: `apps/journeys-admin/src/components/Editor/Slider/JourneyFlow/libs/transformSteps/transformSteps.ts` + +#### Card selection as context + +Users can click a card on the canvas to select it as context for their next message. Selected cards show a primary-colored border with a glow shadow. The chat input displays a dismissible context pill showing the card's heading (e.g. `card-question ×`). When sent, the context card ID is included in the subscription input so the AI knows which card the user is referring to. + +```typescript +// Subscription input addition +type JourneyAiChatInput = { + // ...existing fields + contextCardId?: string // Selected card ID — included in system prompt as "The user is referring to this card" +} +``` + +The context pill appears above the text input area inside the input field. Clicking × or pressing Escape deselects the card. Selecting a different card replaces the current context. + +**Input validation:** +- `input.message` limited to 4000 characters +- `maxTokens` set on `streamText` call: 4096 for text generation +- `input.history` entries each limited to 8000 characters + +#### Canvas auto-pan during execution + +```typescript +if (msg.type === 'plan_progress' && msg.status === 'running' && msg.cardId) { + const node = reactFlowInstance.getNode(msg.cardId) + if (node) reactFlowInstance.setCenter(node.position.x, node.position.y, { zoom: 0.8, duration: 400 }) +} +``` + +#### Canvas layout for branching flows + +Linear journeys (A → B → C) are trivial to lay out. Branching journeys — where a poll or multiple buttons fan out to different cards — need a layout algorithm. + +**Layout strategy: dagre auto-layout** + +Use `@dagrejs/dagre` (already used by React Flow examples) for directed graph layout: + +```typescript +import dagre from '@dagrejs/dagre' + +function layoutNodes(nodes: Node[], edges: Edge[]): Node[] { + const g = new dagre.graphlib.Graph() + g.setDefaultEdgeLabel(() => ({})) + g.setGraph({ rankdir: 'LR', ranksep: 120, nodesep: 40 }) + + nodes.forEach((node) => g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })) + edges.forEach((edge) => g.setEdge(edge.source, edge.target)) + + dagre.layout(g) + + return nodes.map((node) => { + const pos = g.node(node.id) + return { ...node, position: { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 } } + }) +} +``` + +- `rankdir: 'LR'` — left-to-right flow matching existing editor convention +- `ranksep: 120` — horizontal gap between columns (accounts for edge labels) +- `nodesep: 40` — vertical gap between cards in the same column (branching targets) + +Re-layout is triggered after every AI execution turn completes (when the canvas re-renders with updated data). + +**Source handles for multiple outgoing edges** + +`AiCardPreviewNode` renders small source handle dots on the right edge of the card, spaced vertically — one per outgoing connection: + +```typescript +const sourceHandles = outgoingEdges.map((edge, i) => { + const yOffset = ((i + 1) / (outgoingEdges.length + 1)) * NODE_HEIGHT + return ( + + ) +}) +``` + +This ensures edges exit at different Y positions and don't overlap. + +**Edge paths for branching** + +Use React Flow's `BezierEdge` (default) for non-horizontal connections. When source and target Y positions differ by more than 20px, the bezier curve provides a clean visual path. For near-horizontal connections, the curve degrades gracefully to near-straight. + +**Target handle**: Single centered handle on the left edge of each card (all incoming edges converge to one point). + +#### Subscription hook + +```typescript +function useJourneyAiChatSubscription() { + const [messages, setMessages] = useState([]) + const [activeCardIds, setActiveCardIds] = useState>(new Set()) + const [subscriptionInput, setSubscriptionInput] = useState(null) + const client = useApolloClient() + + useSubscription(JOURNEY_AI_CHAT_SUBSCRIPTION, { + skip: subscriptionInput == null, + variables: { input: subscriptionInput! }, + onData: ({ data }) => { + const msg = data.data?.journeyAiChatCreateSubscription + if (!msg) return + + // Coalesce text deltas into single message + if (msg.type === 'text') { + setMessages(prev => { + const last = prev[prev.length - 1] + if (last?.type === 'text' && last?.role === 'assistant') + return [...prev.slice(0, -1), { ...last, text: last.text + msg.text }] + return [...prev, { ...msg, role: 'assistant' }] + }) + } else { + setMessages(prev => [...prev, msg]) + } + + // Track active card IDs + if (msg.type === 'plan_progress' && msg.cardId) { + if (msg.status === 'running') setActiveCardIds(prev => new Set([...prev, msg.cardId])) + else setActiveCardIds(prev => { const n = new Set(prev); n.delete(msg.cardId); return n }) + } + + // Refetch on done + if (msg.type === 'done' && msg.journeyUpdated) { + client.query({ query: GET_ADMIN_JOURNEY, variables: { id: journeyId }, fetchPolicy: 'network-only' }) + } + if (msg.type === 'done') setSubscriptionInput(null) + } + }) + + return { messages, activeCardIds, send: setSubscriptionInput, isActive: subscriptionInput != null } +} +``` + +#### Stop button + +Chat input shows [■ Stop] button while AI is active, replacing [Send ↵]: + +```typescript +{isActive + ? + : +} +``` + +#### Files to create (Phase 2) + +| File | Purpose | +|------|---------| +| `pages/journeys/[journeyId]/ai-chat.tsx` | Page route | +| `src/components/AiEditor/AiEditor.tsx` | Main layout | +| `src/components/AiEditor/AiEditorToolbar/AiEditorToolbar.tsx` | Top bar | +| `src/components/AiEditor/AiJourneyFlow/AiJourneyFlow.tsx` | React Flow canvas | +| `src/components/AiEditor/AiJourneyFlow/nodes/AiCardPreviewNode/AiCardPreviewNode.tsx` | Full card preview node | +| `src/components/AiEditor/AiJourneyFlow/edges/AiViewEdge/AiViewEdge.tsx` | Labeled edge | +| `src/components/AiEditor/AiChatPanel/AiChatPanel.tsx` | Chat panel | +| `src/components/AiEditor/AiChatPanel/MessageBubble/MessageBubble.tsx` | Message display | +| `src/components/AiEditor/AiChatPanel/PlanCard/PlanCard.tsx` | Plan operations display | +| `src/components/AiEditor/AiChatPanel/ToolCallCard/ToolCallCard.tsx` | Tool progress display | +| `src/components/AiEditor/AiChatPanel/StarterSuggestions/StarterSuggestions.tsx` | Initial suggestion chips | +| `src/libs/useJourneyAiChatSubscription/useJourneyAiChatSubscription.ts` | Subscription hook | +| `src/components/AiEditor/AiJourneyFlow/libs/layoutNodes/layoutNodes.ts` | Dagre auto-layout for branching | +| `src/components/Editor/Toolbar/Items/AIEditorItem/AIEditorItem.tsx` | Toolbar button | + +All paths relative to `apps/journeys-admin/`. + +Modified (minimal): +- `apps/journeys-admin/src/components/Editor/Toolbar/Toolbar.tsx` — add AIEditorItem between Items and PreviewItem +- `apps/journeys-admin/src/components/Editor/Slider/JourneyFlow/libs/transformSteps/transformSteps.ts` — add optional `labelEdges` parameter + +--- + +### Phase 3: Extended AI Capabilities + +**3a: Theme/Appearance Tool** — Add `update_journey_theme` tool to the agent. + +**3b: AI Image Search Tool** — Extract `searchUnsplashPhotos` from `apis/api-media/src/schema/unsplash/service.ts` to shared NX lib (`libs/shared/unsplash/`). Importable by both api-media and api-journeys-modern. + +**Phase 1 workaround**: Until `searchUnsplashPhotos` is extracted to a shared lib (Phase 3b), the `search_images` AI tool calls the Unsplash API directly using the existing `UNSPLASH_ACCESS_KEY` environment variable. This is a direct HTTP call (no shared lib needed), duplicating ~20 lines from `api-media`. Extracted to shared lib in Phase 3b. + +`search_images` accepts a `queries: string[]` parameter (up to 5 queries) and returns results grouped by query. This lets the AI search for multiple card images in a single tool call, staying within `maxSteps`. + +**3c: Journey Creation Entry Point** — "Create with AI" button on journey list page: +1. Creates empty journey via `journeyCreate` +2. Navigates directly to `/journeys/[id]/ai-chat` +3. Chat panel opens with pre-filled prompt: "Let's build your journey. What's the goal?" + +--- + +### Phase 4: MCP Server (DEFERRED — post-MVP) + +External agents can create and edit journeys via MCP, authenticating with their existing journeys account. Deferred due to OAuth 2.0 + Firebase delegation complexity. Architecturally independent of Phases 0-2. + +--- + +## User Flow + +### Entry Point 1: Existing editor → AI Editor +``` +Journey List (/) → Click journey card → /journeys/[journeyId] (editor) + → Toolbar: [...existing...] [✨ AI Editor] [Preview] + → /journeys/[journeyId]/ai-chat + → AiEditor: canvas (left 60%) + chat panel (right 40%) + → Chat shows starter suggestion chips based on journey state + → "← Edit Manually" returns to /journeys/[journeyId] +``` + +### Entry Point 2: Create with AI (Phase 3c) +``` +Journey List (/) → "Create with AI" button + → journeyCreate mutation (empty journey with defaults) + → /journeys/[newJourneyId]/ai-chat + → Chat pre-filled: "Let's build your journey. What's the goal?" +``` + +### Chat interaction flow +``` +User types message → Send + → SSE subscription starts + → "Thinking..." indicator shown immediately + → Phase 1 (Plan): AI text streams word-by-word + → Tool call cards appear ("Searching images...") + → Plan card appears with operations list + → Phase 2 (Execute): Server runs each operation + → Cards shimmer on canvas (auto-pans to active card) + → Plan card updates: ⏸ pending → 🔄 running → ✅ done / ❌ failed + → Done: canvas re-renders via Apollo refetch + → Post-execution validation result shown if issues found + → User can: type another message, click "Undo all", click "■ Stop", or switch to manual editor +``` + +--- + +## UX Polish + +- **First-time tooltip** on "✨ AI Editor" button: "Describe changes in plain English — AI builds them for you" +- **Model indicator** in chat panel: "AI-powered · Use your own Claude key →" (not technical model names) +- **"Thinking..." animated indicator** before text starts streaming +- **Canvas auto-pans** to the card being edited during execution +- **"Edit Manually" navigation guard**: + - If AI is actively streaming or executing: block navigation, show confirmation: "AI is still working. Stop and switch to manual editing?" + - If conversation has messages (chat history exists): show confirmation: "Switch to manual editing? Your chat history and undo points will be cleared." + - If chat is empty (no messages sent): navigate immediately, no confirmation + - On confirm: abort any active subscription, navigate to `/journeys/[id]`, show "Changes saved" toast (Snackbar, auto-dismiss 2s) +- **Empty state after refresh or return**: "Start a new conversation. Your previous changes are already saved in the journey." + starter suggestion chips. Previous undo points are no longer accessible. +- **Error boundary** around AiEditor with "Something went wrong" fallback + "Back to Editor" link + +--- + +## System-Wide Impact + +### Interaction Graph + +``` +User message + → journeyAiChatCreateSubscription + → Phase 1: getAgentJourney → inject into system prompt → streamText with tools + → AI calls submit_plan → yield { type: 'plan', operations[] } + → Phase 2: server executes each operation via Prisma + yield plan_progress 'running' / 'done' / 'failed' + → validate_journey (post-execution) + → yield { type: 'done', turnId, validation } + → Client: apolloClient.query GET_ADMIN_JOURNEY (network-only) + → AiJourneyFlow re-renders with updated BlockRenderer previews +``` + +### Error & Failure Propagation + +- **AI API timeout/error**: Caught in subscription handler, yields `{ type: 'error' }`, ends gracefully +- **Step failure during execution**: `plan_progress(operationId, 'failed', error)` + text explanation + `done`. Snapshot valid for undo. +- **`updateSimpleJourney` Zod validation failure**: Thrown inside `executeOperation`. Plan card shows ❌ failed. +- **Auth failure**: `ForbiddenError` before subscription starts. Chat panel shows error state. +- **Optimistic lock conflict** (`CONFLICT`): Plan card shows ❌ failed. Client refreshes before next turn. +- **SSE disconnect / Stop button**: AbortController stops new operations. Completed steps remain applied. Snapshot valid for undo. +- **Upstream API rate limit (429)**: Catch 429 responses from Gemini or Anthropic APIs. For free tier (Gemini): show "AI service temporarily unavailable — try again shortly." For BYOK Claude: extract `retry-after` header from Anthropic's response, show "Your Claude API rate limit reached — resets in X minutes" with option to switch to manual editing. **Concurrent subscription limit:** Only 1 active subscription per user at a time. Starting a new subscription while one is active aborts the previous one. This prevents tab-spam abuse without restricting normal usage. No per-hour message caps — Google's own quotas apply for the free tier, and Anthropic's limits apply for BYOK. Upstream 429 responses are caught and surfaced to the user. +- **Confirmation timeout** (plan stored server-side but not executed): Plan expires after 30 min. Error message shown. +- **Navigation during active AI operation**: "Edit Manually" click during streaming/execution shows confirmation dialog. On confirm: AbortController stops the subscription, completed operations persist, remaining are cancelled. User sees partial changes in manual editor. +- **Invalid user API key**: Test call fails during key setup. Error shown in settings modal. + +### Integration Test Scenarios + +1. **Create from scratch**: Send "Build a 3-card welcome flow" → `journeySimpleGet` returns 3 cards, each with valid navigation +2. **Edit existing journey**: "Add a poll to the second card" → card gains poll blocks, other cards unchanged +3. **Plan mode with confirmation**: "Delete all 8 cards and rebuild" → plan shown with `requiresConfirmation: true`, Execute clicked → full replacement +4. **Mid-plan failure recovery**: AI plans 4 operations; step 3 fails → 2 ✅ + 1 ❌ + 1 ⏸; user undoes → pre-turn snapshot restored +5. **`insertAfterCard` nav rewiring**: Journey A→B→C; "add a card between B and C" → A→B→D→C +6. **`redirectTo` on delete**: Journey A→B→C→D; "remove C" → B navigates to D +7. **Concurrent edit protection**: Manual edit while AI streams → `expectedUpdatedAt` mismatch → agent reports conflict +8. **Undo AI edit**: AI updates 3 cards → user clicks "Undo all" → pre-turn snapshot restored +9. **Stop mid-execution**: User clicks ■ Stop after 2 of 4 operations → 2 ✅ + 2 ⏸, undo available +10. **Tiered model**: Free user → Gemini Flash. BYOK user → Claude Sonnet 4. Same UX. +11. **Background video**: Card with backgroundVideo + heading + button renders correctly +12. **Branching layout**: Journey with poll card (3 options → 3 different cards) renders with dagre layout, edges don't overlap, labels distinguish each path + +--- + +## Acceptance Criteria + +### Functional + +- [ ] "✨ AI Editor" in existing editor Toolbar navigates to `/journeys/[id]/ai-chat` +- [ ] AI editor canvas shows full card previews (all blocks rendered via BlockRenderer, scrollable within node) +- [ ] Canvas edges are labeled with connection type (Button: "X", Poll: "Y", Default) +- [ ] Canvas auto-pans to the card being edited +- [ ] Cards shimmer while AI is actively modifying them +- [ ] User can type a message and receive a streaming response (word by word) +- [ ] "Thinking..." indicator shown before first text arrives +- [ ] Plan card shows before execution with operation list and status indicators +- [ ] Destructive operations (generate_journey, >3 deletions) show [Execute] / [Cancel] +- [ ] AI can create a journey from scratch in a single message +- [ ] AI can edit existing journeys: add/remove cards, change text, update navigation +- [ ] Tool call progress shown inline ("Searching images..." / "Validating journey...") +- [ ] Canvas re-renders with updated block content after AI completes +- [ ] Post-execution validation warns about orphaned cards or dead-end navigation +- [ ] Hover over user message bubble reveals undo icon at bottom-right corner +- [ ] Clicking undo icon shows inline confirmation with contextual description of changes to revert +- [ ] Confirming undo restores the pre-turn snapshot; undoing past turns warns about cascading revert +- [ ] ■ Stop button cancels AI mid-request +- [ ] Starter suggestion chips shown on empty chat +- [ ] "Changes saved" toast when switching to manual editor +- [ ] Conversation history maintained within session (ephemeral, lost on refresh) +- [ ] Free tier (Gemini Flash) works without any setup +- [ ] Premium tier (Claude) works after user enters Anthropic API key +- [ ] Content-derived card IDs (card-welcome, not card-1) for existing journeys +- [ ] Clicking a card on the canvas selects it as context; context pill shown in chat input; card ID sent to subscription +- [ ] Branching journeys (poll/multi-button) render with fanned-out target cards and non-overlapping bezier edges +- [ ] Upstream API 429 errors are caught and shown as friendly messages with retry timing (Claude BYOK extracts `retry-after` header) +- [ ] Users can switch between free and premium tiers mid-conversation without losing chat context +- [ ] "Edit Manually" shows confirmation dialog when chat history exists or AI is active +- [ ] Active AI operations are cleanly aborted before navigation to manual editor + +### Non-Functional + +- [ ] First AI response token streams within 2 seconds +- [ ] `generate_journey` completes in under 5 seconds for journeys up to 20 cards +- [ ] Server-driven execution completes all operations in under 2 seconds +- [ ] Prompt injection hardened via `hardenPrompt` + `preSystemPrompt` +- [ ] Auth: subscription requires valid Firebase JWT +- [ ] User API keys encrypted server-side (AES-256/GCP KMS), never in browser JS + +### Quality Gates + +- [ ] Unit tests for `journeyAiChatCreateSubscription` (mock AI SDK) +- [ ] Unit tests for `AiChatPanel`, `AiCardPreviewNode`, `PlanCard` components +- [ ] `nx type-check shared-ai && nx type-check api-journeys-modern` passes +- [ ] `nx build api-journeys-modern` passes +- [ ] `nx build journeys-admin` passes +- [ ] `nx generate-graphql api-journeys-modern` generates schema with new types +- [ ] No unused imports, no empty catch blocks + +--- + +## Dependencies & Prerequisites + +| Dependency | Notes | +|---|---| +| `@ai-sdk/anthropic` | Add to root package.json (for BYOK users) | +| `@google-cloud/kms` | For API key encryption (or Firebase server-side encryption) | +| `GOOGLE_GENERATIVE_AI_API_KEY` | Already configured — powers free tier | +| `JourneySimple` Phase 0 redesign | Must complete before Phase 1 | +| `BlockRenderer` (viewer mode) | Already in `libs/journeys/ui` — needs `ThemeProvider` + `JourneyProvider` wrapping | +| `CARD_WIDTH` / `CARD_HEIGHT` | Already in `Canvas/utils/calculateDimensions/` — 324×674px | +| `searchUnsplashPhotos` | Extract from `api-media` to shared NX lib (`libs/shared/unsplash/`) | +| `journeyAiTranslateCreate` | Already exists — `translate_journey` tool delegates to it | +| SSE subscription infrastructure | Already wired via `SSELink` + `graphql-sse` in Apollo Client | +| `hardenPrompt` + `preSystemPrompt` | Already in `libs/shared/ai/src/prompts/` | +| Redis (`libs/yoga/src/redis/connection.ts`) | Already available — used for per-turn snapshot + plan storage with TTL | +| `@dagrejs/dagre` | Directed graph layout for branching journey flows | +| `transformSteps` | Existing utility — add optional `labelEdges` parameter | + +--- + +## NX Commands + +```bash +# Phase 0 — after schema changes: +nx prisma-validate prisma-journeys +nx prisma-generate prisma-journeys # MUST run after Prisma schema changes +nx prisma-migrate prisma-journeys # If adding UserApiKey model +nx type-check shared-ai +nx test shared-ai +nx type-check api-journeys-modern +nx test api-journeys-modern +nx build api-journeys-modern + +# Phase 1 — after Pothos schema changes: +nx generate-graphql api-journeys-modern # MUST run after adding subscription/mutation types + +# Phase 2: +nx build journeys-admin +``` + +--- + +## Risk Analysis + +| Risk | Likelihood | Impact | Mitigation | +|---|---|---|---| +| BlockRenderer in React Flow node causes performance issues | Low | Medium | React.memo on AiCardPreviewNode, pointerEvents: none, maxHeight: 400 with scroll | +| AI generates invalid navigation (orphaned cards) | Low | Medium | Zod superRefine + post-execution validate_journey + insertAfterCard/redirectTo | +| Gemini Flash quality insufficient for complex edits | Medium | Medium | Users can upgrade to Claude BYOK; system prompt + examples mitigate | +| Canvas re-render flicker after AI edit | Low | Low | Apollo network-only refetch + CSS transitions | +| User API key security | Low | High | AES-256/GCP KMS encryption, never in browser JS, immune to XSS | +| Surgical tool blockId fragility | None (mitigated) | High | All surgical tools use blockId from injected journey state | +| Context window growth in long sessions | Low | Medium | Auto-summarization via same model tier after 20 messages | + +--- + +## `JourneySimple` Remaining Limitations + +After Phase 0, the following are still out of scope: + +- **SignUpBlock** — not in AddBlock UI panel, excluded +- **Mux/Cloudflare video sources** — only YouTube +- **Poll option images** (`pollOptionImageBlockId`) +- **Button icons** (`startIconId`, `endIconId`) +- **VideoTriggerBlock** — time-based navigation trigger +- **GridContainerBlock / GridItemBlock** — confirmed unused +- **Journey-level appearance** (`chatButtons[]`, `host`, custom fonts) — deferred to Phase 3 +- **CardBlock `backdropBlur`** — blur effect not exposed + +The `content` array design makes adding any of these a local change to the discriminated union. + +--- + +## Future Considerations + +- **Persisted conversation history** — store chat messages in `JourneyAiChatSession` table +- **AI journey templates** — pre-seeded prompts for common use cases +- **Multi-journey operations** — "Update all journeys in this team to use new brand colors" +- **Image generation integration** — AI generates background images on the fly +- **Analytics-driven suggestions** — "This card has 60% drop-off. Want me to simplify it?" +- **MCP Server (Phase 4)** — External agents via Model Context Protocol with OAuth 2.0 + +--- + +## Sources & References + +### Internal + +- `JourneySimple` schema: `libs/shared/ai/src/journeySimpleTypes.ts` +- `getSimpleJourney`: `apis/api-journeys-modern/src/schema/journey/simple/getSimpleJourney.ts` +- `updateSimpleJourney`: `apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.ts` +- `simplifyJourney`: `apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.ts` +- Streaming pattern: `apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyAiTranslate.ts` +- Prompt hardening: `libs/shared/ai/src/prompts/hardeningPrompt/`, `libs/shared/ai/src/prompts/preSystemPrompt.ts` +- `BlockRenderer` (viewer mode): `libs/journeys/ui/src/components/BlockRenderer/BlockRenderer.tsx` +- Card dimensions: `apps/journeys-admin/src/components/Editor/Slider/Content/Canvas/utils/calculateDimensions/` +- React Flow nodes: `apps/journeys-admin/src/components/Editor/Slider/JourneyFlow/nodes/` +- React Flow edges: `apps/journeys-admin/src/components/Editor/Slider/JourneyFlow/edges/` +- `transformSteps`: `apps/journeys-admin/src/components/Editor/Slider/JourneyFlow/libs/transformSteps/transformSteps.ts` +- Editor component tree: `apps/journeys-admin/src/components/Editor/Editor.tsx` +- Apollo SSE client: `apps/journeys-admin/src/libs/apolloClient/apolloClient.ts` +- EditorProvider: `libs/journeys/ui/src/libs/EditorProvider/EditorProvider.tsx` +- CommandProvider: `libs/journeys/ui/src/libs/CommandProvider/CommandProvider.tsx` +- JourneyProvider: `libs/journeys/ui/src/libs/JourneyProvider/JourneyProvider.tsx` +- Unsplash: `apis/api-media/src/schema/unsplash/service.ts` +- Redis: `libs/yoga/src/redis/connection.ts` +- Prisma Block model: `libs/prisma/journeys/db/schema.prisma:534-638` +- Prisma Action model: `libs/prisma/journeys/db/schema.prisma:640-660` +- Builder config: `apis/api-journeys-modern/src/schema/builder.ts` + +### External + +- Vercel AI SDK tool-use: https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling +- Pothos GraphQL schema builder: https://pothos-graphql.dev/docs diff --git a/docs/plans/2026-03-19-ai-chat-editor-implementation-progress.md b/docs/plans/2026-03-19-ai-chat-editor-implementation-progress.md new file mode 100644 index 00000000000..b4ac3a46a33 --- /dev/null +++ b/docs/plans/2026-03-19-ai-chat-editor-implementation-progress.md @@ -0,0 +1,155 @@ +--- +title: AI Chat Editor — Implementation Progress +date: 2026-03-19 +branch: 25-03-RB-feat-ai-chat-journey-editor +plan: 2026-03-18-001-feat-ai-chat-journey-editor-plan.md +--- + +# Implementation Progress + +## Branch: `25-03-RB-feat-ai-chat-journey-editor` + +### Commits + +| # | Hash | Phase | Description | +|---|------|-------|-------------| +| 1 | `f8dd50753` | Phase 0 | Schema redesign — content array, 8 block types, 5 action kinds | +| 2 | `89063dd98` | Phase 1 | Backend AI agent — subscription, tools, system prompt, undo | +| 3 | `2d4031c91` | Codegen | GraphQL schema for journeyAiChat | +| 4 | `f6325e1c8` | Phase 2 | Frontend AI Editor — canvas, chat panel, 30+ components | +| 5 | `a9f17590c` | Codegen | Gateway schema + Apollo client codegen | + +### Verification Status + +| Check | Status | +|-------|--------| +| `nx type-check shared-ai` | Pass | +| `nx type-check api-journeys-modern` | Pass | +| `nx test shared-ai` | 51 tests pass | +| `nx test api-journeys-modern` | 522 tests pass | +| `nx build api-journeys-modern` | Pass | +| `nx codegen journeys-admin` | 327 types generated | +| Gateway schema composition | Pass | +| Frontend ESLint (our components) | Clean | +| `nx build journeys-admin` | Fails on pre-existing SSR prerender (needs running API server via `nf start`) | + +--- + +## Phase 0: Complete + +**JourneySimple schema redesign** — full rewrite of 3 core files + 4 test files. + +Files modified: +- `libs/shared/ai/src/journeySimpleTypes.ts` — content array with discriminated union +- `apis/api-journeys-modern/src/schema/journey/simple/simplifyJourney.ts` — emits new format +- `apis/api-journeys-modern/src/schema/journey/simple/updateSimpleJourney.ts` — ingests new format +- All corresponding `.spec.ts` test files + +Key changes: +- Flat card fields (`heading`, `text`, `button`, `poll`) → ordered `content: JourneySimpleBlock[]` array +- All 8 addable block types: heading, text, button, image, video, poll, multiselect, textInput, spacer +- All 5 action kinds: navigate, url, email, chat, phone +- Content-derived card IDs (e.g. `card-welcome` not `card-1`) +- Cross-card reference validation via Zod superRefine +- Image width/height/blurhash made optional (server computes) +- Card x/y made optional (auto-layout) +- URL scheme validation (https/http only) +- Image I/O moved outside Prisma transaction +- `@ai-sdk/anthropic` dependency added + +--- + +## Phase 1: Complete + +**Backend AI Agent** — 8 new files + 2 modified. + +Files created: +- `libs/shared/ai/src/agentJourneyTypes.ts` — AgentJourney, PlanOperation, NavigationMap types +- `apis/api-journeys-modern/src/schema/journeyAiChat/journeyAiChat.ts` — main subscription + mutations +- `apis/api-journeys-modern/src/schema/journeyAiChat/getAgentJourney.ts` — journey state reader for AI +- `apis/api-journeys-modern/src/schema/journeyAiChat/executeOperation.ts` — surgical tool executor +- `apis/api-journeys-modern/src/schema/journeyAiChat/systemPrompt.ts` — full AI system prompt +- `apis/api-journeys-modern/src/schema/journeyAiChat/snapshotStore.ts` — Redis undo snapshots +- `apis/api-journeys-modern/src/schema/journeyAiChat/tools/searchImages.ts` — Unsplash batch search +- `apis/api-journeys-modern/src/schema/journeyAiChat/index.ts` — barrel + +Files modified: +- `apis/api-journeys-modern/src/env.ts` — added ANTHROPIC_API_KEY (optional) +- `apis/api-journeys-modern/src/schema/schema.ts` — wired journeyAiChat + +Key features: +- GraphQL SSE subscription (`journeyAiChatCreateSubscription`) +- Two-phase loop: AI plans via `submit_plan`, server executes +- 10 surgical tools (createCard, deleteCard, updateCard, addBlock, updateBlock, deleteBlock, reorderCards, updateJourneySettings, generate_journey, translate) +- `search_images` batch tool (Unsplash, up to 5 queries) +- Full system prompt with examples and behavioral constraints +- Redis-based undo snapshots with per-turn checkpoints +- `journeyAiChatUndo` + `journeyAiChatExecutePlan` mutations +- Tiered model selection (Gemini Flash free / Claude BYOK) +- Mid-session model switching via `preferredTier` +- Published journey warning +- Prompt hardening (preSystemPrompt + hardenPrompt) +- History validation (roles, length, entry limits) +- Server-derived `requiresConfirmation` +- `sanitizeErrorMessage` for safe error propagation +- Vercel AI SDK v4 compatible (`inputSchema`, `tool()`, `stepCountIs()`) + +--- + +## Phase 2: Complete + +**Frontend AI Editor** — 31 new files + 4 modified. + +Page route: +- `apps/journeys-admin/pages/journeys/[journeyId]/ai-chat.tsx` + +Components (all in `apps/journeys-admin/src/components/AiEditor/`): +- `AiEditor.tsx` — main 60/40 split layout +- `AiEditorToolbar/` — toolbar with Edit Manually, title, Stop button +- `AiJourneyFlow/` — React Flow canvas with dagre auto-layout + - `nodes/AiCardPreviewNode/` — full card preview at 0.55 scale + - `edges/AiViewEdge/` — labeled edges (default/button/poll) + - `libs/layoutNodes/` — dagre positioning +- `AiChatPanel/` — chat panel with messages, input, model indicator + - `MessageBubble/` — user/AI bubbles with hover-to-reveal undo + - `PlanCard/` — operation status (pending/running/done/failed) + - `StarterSuggestions/` — empty state with suggestion chips + - `ChatInput/` — input with context pill, send/stop toggle + - `ModelIndicator/` — free/premium tier display + - `ToolCallCard/` — tool progress display + +Hook: +- `apps/journeys-admin/src/libs/useJourneyAiChatSubscription/` + +Entry point: +- `apps/journeys-admin/src/components/Editor/Toolbar/Items/AIEditorItem/` + +Dependencies added: +- `@dagrejs/dagre` — branching canvas layout + +--- + +## Phase 3: Not Started + +Deferred items: +- Theme/Appearance tool +- AI Image Search extraction to shared NX lib +- "Create with AI" journey list entry point +- UserApiKey Prisma model + migration (BYOK encryption) + +--- + +## Design Artifacts + +- **Pencil mockups**: `pencil-new.pen` — 16 screens + review board +- **Plan document**: `docs/plans/2026-03-18-001-feat-ai-chat-journey-editor-plan.md` +- **Low priority findings**: `docs/plans/2026-03-19-ai-chat-editor-low-priority-findings.md` + +--- + +## Known Issues + +1. `nx build journeys-admin` fails on SSR prerender for `/en/templates` — pre-existing issue, needs running API server (`nf start`) +2. `journeyAiTranslate.spec.ts` has a pre-existing test failure (AI SDK version mismatch) +3. Frontend component tests not yet written +4. BYOK key encryption uses env var as placeholder — needs UserApiKey Prisma model diff --git a/docs/plans/2026-03-19-ai-chat-editor-low-priority-findings.md b/docs/plans/2026-03-19-ai-chat-editor-low-priority-findings.md new file mode 100644 index 00000000000..f3b355a74f3 --- /dev/null +++ b/docs/plans/2026-03-19-ai-chat-editor-low-priority-findings.md @@ -0,0 +1,63 @@ +--- +title: AI Chat Editor — Low Priority Findings +type: review +status: backlog +date: 2026-03-19 +parent: 2026-03-18-001-feat-ai-chat-journey-editor-plan.md +--- + +# Low Priority Findings + +Review findings from the plan & design review that are nice-to-have improvements. These do not block implementation but should be addressed post-MVP. + +## L1: Keyboard navigation for canvas card selection + +**Source:** UX Review + +No keyboard navigation support for selecting cards on the canvas. Tab order through the interface is undefined. Card selection (click to select, Escape to deselect) needs keyboard equivalents (arrow keys to navigate between cards, Enter to select as context). + +**Recommendation:** Define Tab order: toolbar → canvas cards (arrow key navigation) → chat input → send button. Add `role="listbox"` to canvas, `role="option"` to card nodes. + +--- + +## L2: First-time user onboarding tooltip + +**Source:** UX Review + +Plan mentions a "first-time tooltip on the AI Editor button" but no mockup exists. Beyond empty state suggestion chips, there is no progressive guidance for first-time users. + +**Recommendation:** Add a tooltip/coach mark sequence on first visit: (1) "This is your journey canvas" pointing to cards, (2) "Describe what you want here" pointing to chat input, (3) "Or try a suggestion" pointing to chips. Store `hasSeenAiEditorOnboarding` in localStorage. + +--- + +## L3: Complete PlanOperation schema for all 10 tool variants + +**Source:** AI Review + +The `PlanOperation` discriminated union only shows 2 of 10 tool variants in full. The rest is `// ... etc for each tool`. While the surgical tool specs describe behavior, the actual Zod schemas for `update_block`, `delete_block`, `add_block` args need full type definitions. + +**Recommendation:** Write out all 10 variants with complete `args` objects before Phase 1 implementation begins. + +--- + +## L4: Multiselect limitations in system prompt + +**Source:** AI Review + +Multiselect options are `string[]` with no actions, but the system prompt doesn't explain this. The AI might try to add navigation actions to multiselect options and fail at Zod validation. + +**Recommendation:** Add to system prompt: "Multiselect options are text-only — they do not support navigation actions. Use poll blocks for options that need to navigate to different cards." + +--- + +## L5: BYOK stolen key prevention + +**Source:** Security Review + +No way to prevent use of a stolen Anthropic API key. The application validates the key works but not that it belongs to the user. + +**Recommendation:** +- Add ToS checkbox: "I confirm this API key belongs to me" +- Log IP + userId on key registration +- Store key hash to detect same key registered by multiple users +- Monitor for unusual patterns diff --git a/docs/solutions/architecture/ai-chat-journey-editor-full-stack-pattern.md b/docs/solutions/architecture/ai-chat-journey-editor-full-stack-pattern.md new file mode 100644 index 00000000000..a598be672cb --- /dev/null +++ b/docs/solutions/architecture/ai-chat-journey-editor-full-stack-pattern.md @@ -0,0 +1,169 @@ +--- +title: "AI Chat Journey Editor — Full-Stack Feature Pattern" +category: architecture +date: 2026-03-19 +tags: + - ai-agent + - graphql-subscription + - react-flow + - vercel-ai-sdk + - two-phase-execution + - pencil-mockups + - plan-driven-development +modules: + - api-journeys-modern + - journeys-admin + - shared-ai +severity: n/a +resolution_time: "1 session (~4 hours)" +--- + +# AI Chat Journey Editor — Full-Stack Feature Pattern + +## Problem + +Build a complete AI-powered editing experience for a complex block-based content system (journeys). Non-technical users need to describe changes in natural language and see them executed in real time on a visual canvas. This required coordinating: schema redesign, backend AI agent with tools, GraphQL streaming, React Flow canvas with full card previews, and a chat interface — all in one feature. + +## Root Cause / Challenge + +The existing `JourneySimple` schema only covered ~50% of block types and used a flat field structure that prevented multiple blocks of the same type. The AI couldn't create multiselect, text input, or spacer blocks. Navigation actions were limited to navigate/url only (missing email, chat, phone). This made the AI unreliable for real editing. + +## Solution + +### Architecture: Plan → Design → Implement (in phases) + +**Phase 0 (Schema):** Redesigned `JourneySimple` with a `content: JourneySimpleBlock[]` discriminated union array. All 8 block types, all 5 action kinds, cross-card reference validation, content-derived card IDs. + +**Phase 1 (Backend):** Two-phase agent loop — AI plans via `submit_plan` tool, server executes operations directly. Surgical tools for targeted edits (`addBlock`, `updateBlock`, etc.) alongside atomic `generate_journey` for full rewrites. Redis undo snapshots, tiered model selection (Gemini free / Claude BYOK). + +**Phase 2 (Frontend):** Split-pane editor — React Flow canvas (60%) with full card previews at 0.55 scale + chat panel (40%) with streaming messages, plan cards, suggestion chips. + +### Key Technical Decisions + +#### 1. Two-phase loop (AI plans, server executes) + +``` +User message → AI streams text + calls submit_plan → Server executes each operation → Canvas updates +``` + +**Why:** Server-driven execution completes in ~0.5s vs ~15s for AI-driven. The AI only makes decisions once; server handles I/O. + +#### 2. Vercel AI SDK v4 tool definition pattern + +```typescript +import { stepCountIs, streamText, tool } from 'ai' + +const result = streamText({ + model, + system: fullSystemPrompt, + messages, + tools: { + search_images: tool({ + description: '...', + inputSchema: z.object({ queries: z.array(z.string()) }), + execute: async ({ queries }) => { ... } + }), + submit_plan: tool({ + description: '...', + inputSchema: planOperationArraySchema, + execute: async (args) => args + }) + }, + stopWhen: stepCountIs(5) +}) + +for await (const part of result.fullStream) { + switch (part.type) { + case 'text-delta': /* part.text (not part.textDelta) */ + case 'tool-call': /* part.input (not part.args) */ + case 'tool-result': /* part.output (not part.result) */ + } +} +``` + +**Critical:** AI SDK v4 renamed: `parameters` → `inputSchema`, `maxSteps` → `stopWhen: stepCountIs(N)`, `textDelta` → `text`, `args` → `input`, `result` → `output`. + +#### 3. Content-derived card IDs + +```typescript +function generateCardIds(cards) { + const usedIds = new Set() + return cards.map((card) => { + const slug = (card.heading ?? card.firstText ?? 'untitled') + .toLowerCase().replace(/[^a-z0-9\s]/g, '').trim() + .split(/\s+/).slice(0, 4).join('-') || 'untitled' + let id = `card-${slug}` + let counter = 2 + while (usedIds.has(id)) { id = `card-${slug}-${counter++}` } + usedIds.add(id) + return id + }) +} +``` + +**Why:** Semantic IDs (`card-welcome`) help the AI reason about card relationships vs positional IDs (`card-1`). + +#### 4. Prompt hardening for journey state injection + +```typescript +const fullSystemPrompt = `${preSystemPrompt}\n\n${systemPrompt}\n\n## Current Journey State\n${hardenPrompt(JSON.stringify(agentJourney))}\n\nSummary: ${agentJourney.cards.length} cards.${contextSuffix}` +``` + +**Why:** User-authored card content is injected into the system prompt — without `hardenPrompt` wrapping, prompt injection via card headings is possible. + +#### 5. Server-derived confirmation (not AI-decided) + +```typescript +const requiresConfirmation = plan.operations.some(o => o.tool === 'generate_journey') + || plan.operations.filter(o => o.tool === 'delete_card').length > 3 +``` + +**Why:** The AI shouldn't decide whether to confirm destructive operations — it could be manipulated via prompt injection to skip confirmation. + +#### 6. Dagre auto-layout for branching flows + +```typescript +import dagre from '@dagrejs/dagre' +const g = new dagre.graphlib.Graph() +g.setGraph({ rankdir: 'LR', ranksep: 120, nodesep: 40 }) +``` + +**Why:** Linear journeys (A→B→C) are trivial, but poll cards branching to 3+ targets need algorithmic positioning. + +### Design Process (Pencil Mockups) + +Created 16 screens covering every UX state: +- Empty state, thinking, streaming, executing (shimmer), completed, undo confirmation +- Error state, rate limit, stop mid-execution, empty journey +- Branching flow visualization, API key setup, Claude connected +- Switch to manual editing confirmation + +**Key design decisions made during mockup iteration:** +- Card selection border: blue (not red — red was overloaded for selected/executing/error) +- Undo: hover-to-reveal on user message bubble (not toolbar button) +- Default next edge: solid gray line (not dashed — dashed was invisible) +- Plan card descriptions: user-friendly language ("Add email input to Welcome card" not "Add TextResponseBlock to card-welcome") + +## Prevention Strategies + +1. **Always check AI SDK version** before writing tool definitions. The API changed significantly between v3 and v4 (`parameters` → `inputSchema`, `maxSteps` → `stopWhen`). + +2. **Move all external I/O outside Prisma transactions** — image uploads, YouTube API calls happen before `$transaction`. + +3. **Use Redis (not lru-cache) for data that must survive pod restarts** — undo snapshots routed to different pods via load balancer. + +4. **Server-derive security-sensitive flags** — confirmation, rate limiting decisions should not be AI-controlled. + +5. **Content-derived IDs need collision handling** — always deduplicate with counter suffixes. + +6. **Run `nx generate-graphql` after adding Pothos types**, then compose gateway schema, then run Apollo client codegen. Three separate steps. + +7. **ESLint in this codebase requires i18n wrapping** — all user-visible strings need `useTranslation` + `t()`. Check this before committing frontend components. + +## Cross-References + +- Plan: `docs/plans/2026-03-18-001-feat-ai-chat-journey-editor-plan.md` +- Progress: `docs/plans/2026-03-19-ai-chat-editor-implementation-progress.md` +- Low-priority findings: `docs/plans/2026-03-19-ai-chat-editor-low-priority-findings.md` +- Design mockups: `pencil-new.pen` +- Existing translate pattern: `apis/api-journeys-modern/src/schema/journeyAiTranslate/journeyAiTranslate.ts` diff --git a/libs/shared/ai/src/agentJourneyTypes.ts b/libs/shared/ai/src/agentJourneyTypes.ts new file mode 100644 index 00000000000..90637c7039c --- /dev/null +++ b/libs/shared/ai/src/agentJourneyTypes.ts @@ -0,0 +1,153 @@ +import { z } from 'zod' + +import { + journeySimpleBlockSchema, + journeySimpleCardSchema, + journeySimpleSchema +} from './journeySimpleTypes' + +// --- AgentJourney: enriched journey state injected into AI system prompt --- + +export interface AgentJourneyCard { + simpleId: string + blockId: string + cardBlockId: string + heading?: string + content: Array & { blockId: string }> + backgroundColor?: string + backgroundImage?: { src: string; alt: string } + backgroundVideo?: { url: string } + defaultNextCard?: string +} + +export interface NavigationMapEntry { + sourceCardId: string + targetCardId: string + trigger: 'default' | 'button' | 'poll' + label?: string + blockId: string +} + +export interface AgentJourney { + title: string + description: string + language: string + cards: AgentJourneyCard[] + navigationMap: NavigationMapEntry[] +} + +// --- PlanOperation: discriminated union by tool --- + +export const planOperationSchema = z.discriminatedUnion('tool', [ + z.object({ + id: z.string(), + description: z.string(), + tool: z.literal('generate_journey'), + cardId: z.string().optional(), + args: z.object({ simple: journeySimpleSchema }) + }), + z.object({ + id: z.string(), + description: z.string(), + tool: z.literal('create_card'), + cardId: z.string().optional(), + args: z.object({ + card: journeySimpleCardSchema, + insertAfterCard: z.string().optional() + }) + }), + z.object({ + id: z.string(), + description: z.string(), + tool: z.literal('delete_card'), + cardId: z.string().optional(), + args: z.object({ + cardId: z.string(), + redirectTo: z.string().optional() + }) + }), + z.object({ + id: z.string(), + description: z.string(), + tool: z.literal('update_card'), + cardId: z.string().optional(), + args: z.object({ + blockId: z.string(), + backgroundColor: z.string().optional(), + backgroundImage: z.object({ + src: z.string(), + alt: z.string() + }).nullable().optional(), + defaultNextCard: z.string().optional(), + x: z.number().optional(), + y: z.number().optional() + }) + }), + z.object({ + id: z.string(), + description: z.string(), + tool: z.literal('add_block'), + cardId: z.string().optional(), + args: z.object({ + cardBlockId: z.string(), + block: journeySimpleBlockSchema, + position: z.number().int().nonnegative().optional() + }) + }), + z.object({ + id: z.string(), + description: z.string(), + tool: z.literal('update_block'), + cardId: z.string().optional(), + args: z.object({ + blockId: z.string(), + updates: z.record(z.string(), z.unknown()) + }) + }), + z.object({ + id: z.string(), + description: z.string(), + tool: z.literal('delete_block'), + cardId: z.string().optional(), + args: z.object({ + blockId: z.string() + }) + }), + z.object({ + id: z.string(), + description: z.string(), + tool: z.literal('reorder_cards'), + cardId: z.string().optional(), + args: z.object({ + blockIds: z.array(z.string()) + }) + }), + z.object({ + id: z.string(), + description: z.string(), + tool: z.literal('update_journey_settings'), + cardId: z.string().optional(), + args: z.object({ + title: z.string().optional(), + description: z.string().optional() + }) + }), + z.object({ + id: z.string(), + description: z.string(), + tool: z.literal('translate'), + cardId: z.string().optional(), + args: z.object({ + targetLanguage: z.string(), + cardIds: z.array(z.string()).optional() + }) + }) +]) + +export type PlanOperation = z.infer + +export const planOperationArraySchema = z.object({ + operations: z.array(planOperationSchema) +}) + +export type PlanOperationArray = z.infer diff --git a/libs/shared/ai/src/journeySimpleTypes.spec.ts b/libs/shared/ai/src/journeySimpleTypes.spec.ts index ee9ab35fdf3..d8ce264c6a8 100644 --- a/libs/shared/ai/src/journeySimpleTypes.spec.ts +++ b/libs/shared/ai/src/journeySimpleTypes.spec.ts @@ -1,542 +1,519 @@ import { - journeySimpleButtonSchema, - journeySimpleButtonSchemaUpdate, + journeySimpleActionSchema, + journeySimpleBlockSchema, journeySimpleCardSchema, - journeySimpleCardSchemaUpdate, - journeySimplePollOptionSchema, - journeySimplePollOptionSchemaUpdate, - journeySimpleVideoSchema, - journeySimpleVideoSchemaUpdate + journeySimpleSchemaUpdate } from './journeySimpleTypes' -// journeySimplePollOptionSchema tests - -describe('journeySimplePollOptionSchema (base, permissive)', () => { - it('validates with only nextCard', () => { - expect( - journeySimplePollOptionSchema.safeParse({ text: 'A', nextCard: 'card-1' }) - .success - ).toBe(true) - }) - - it('validates with only url', () => { - expect( - journeySimplePollOptionSchema.safeParse({ - text: 'A', - url: 'https://a.com' - }).success - ).toBe(true) - }) - - it('validates with both nextCard and url (permissive)', () => { - expect( - journeySimplePollOptionSchema.safeParse({ - text: 'A', - nextCard: 'card-1', - url: 'https://a.com' - }).success - ).toBe(true) +describe('journeySimpleActionSchema', () => { + it('should validate navigate action', () => { + const result = journeySimpleActionSchema.safeParse({ + kind: 'navigate', + cardId: 'card-welcome' + }) + expect(result.success).toBe(true) }) - it('validates with neither nextCard nor url (permissive)', () => { - expect(journeySimplePollOptionSchema.safeParse({ text: 'A' }).success).toBe( - true - ) + it('should validate url action', () => { + const result = journeySimpleActionSchema.safeParse({ + kind: 'url', + url: 'https://example.com' + }) + expect(result.success).toBe(true) }) -}) -describe('journeySimplePollOptionSchemaUpdate (strict)', () => { - it('validates with only nextCard', () => { - expect( - journeySimplePollOptionSchemaUpdate.safeParse({ - text: 'A', - nextCard: 'card-1' - }).success - ).toBe(true) - }) - - it('validates with only url', () => { - expect( - journeySimplePollOptionSchemaUpdate.safeParse({ - text: 'A', - url: 'https://a.com' - }).success - ).toBe(true) - }) - - it('fails with both nextCard and url (strict)', () => { - const result = journeySimplePollOptionSchemaUpdate.safeParse({ - text: 'A', - nextCard: 'card-1', - url: 'https://a.com' + it('should validate email action', () => { + const result = journeySimpleActionSchema.safeParse({ + kind: 'email', + email: 'user@example.com' }) - expect(result.success).toBe(false) - if (!result.success) { - expect(result.error.issues[0].message).toMatch(/Exactly one/) - } + expect(result.success).toBe(true) }) - it('fails with neither nextCard nor url (strict)', () => { - const result = journeySimplePollOptionSchemaUpdate.safeParse({ text: 'A' }) - expect(result.success).toBe(false) - if (!result.success) { - expect(result.error.issues[0].message).toMatch(/Exactly one/) - } + it('should validate chat action', () => { + const result = journeySimpleActionSchema.safeParse({ + kind: 'chat', + chatUrl: 'https://wa.me/1234567890' + }) + expect(result.success).toBe(true) }) -}) -// journeySimpleButtonSchema tests - -describe('journeySimpleButtonSchema (base, permissive)', () => { - it('validates with only nextCard', () => { - expect( - journeySimpleButtonSchema.safeParse({ text: 'B', nextCard: 'card-2' }) - .success - ).toBe(true) + it('should validate phone action with all fields', () => { + const result = journeySimpleActionSchema.safeParse({ + kind: 'phone', + phone: '+1234567890', + countryCode: 'US', + contactAction: 'call' + }) + expect(result.success).toBe(true) }) - it('validates with only url', () => { - expect( - journeySimpleButtonSchema.safeParse({ text: 'B', url: 'https://b.com' }) - .success - ).toBe(true) + it('should validate phone action with minimal fields', () => { + const result = journeySimpleActionSchema.safeParse({ + kind: 'phone', + phone: '+1234567890' + }) + expect(result.success).toBe(true) }) - it('validates with both nextCard and url (permissive)', () => { - expect( - journeySimpleButtonSchema.safeParse({ - text: 'B', - nextCard: 'card-2', - url: 'https://b.com' - }).success - ).toBe(true) + it('should reject url action with javascript: scheme', () => { + const result = journeySimpleActionSchema.safeParse({ + kind: 'url', + url: 'javascript:alert(1)' + }) + expect(result.success).toBe(false) }) - it('validates with neither nextCard nor url (permissive)', () => { - expect(journeySimpleButtonSchema.safeParse({ text: 'B' }).success).toBe( - true - ) + it('should reject chat action with data: scheme', () => { + const result = journeySimpleActionSchema.safeParse({ + kind: 'chat', + chatUrl: 'data:text/html,' + }) + expect(result.success).toBe(false) }) }) -describe('journeySimpleButtonSchemaUpdate (strict)', () => { - it('validates with only nextCard', () => { - expect( - journeySimpleButtonSchemaUpdate.safeParse({ - text: 'B', - nextCard: 'card-2' - }).success - ).toBe(true) - }) - - it('validates with only url', () => { - expect( - journeySimpleButtonSchemaUpdate.safeParse({ - text: 'B', - url: 'https://b.com' - }).success - ).toBe(true) - }) - - it('fails with both nextCard and url (strict)', () => { - const result = journeySimpleButtonSchemaUpdate.safeParse({ - text: 'B', - nextCard: 'card-2', - url: 'https://b.com' +describe('journeySimpleBlockSchema', () => { + it('should validate heading block with default variant', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'heading', + text: 'Welcome' }) - expect(result.success).toBe(false) - if (!result.success) { - expect(result.error.issues[0].message).toMatch(/Exactly one/) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.type).toBe('heading') + if (result.data.type === 'heading') { + expect(result.data.variant).toBe('h3') + } } }) - it('fails with neither nextCard nor url (strict)', () => { - const result = journeySimpleButtonSchemaUpdate.safeParse({ text: 'B' }) - expect(result.success).toBe(false) - if (!result.success) { - expect(result.error.issues[0].message).toMatch(/Exactly one/) + it('should validate heading block with explicit variant', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'heading', + text: 'Welcome', + variant: 'h1' + }) + expect(result.success).toBe(true) + if (result.success && result.data.type === 'heading') { + expect(result.data.variant).toBe('h1') } }) -}) -// journeySimpleVideoSchema tests - -describe('journeySimpleVideoSchema (base, permissive)', () => { - it('validates with only url', () => { - expect( - journeySimpleVideoSchema.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' - }).success - ).toBe(true) - }) - - it('validates with url and startAt', () => { - expect( - journeySimpleVideoSchema.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: 30 - }).success - ).toBe(true) + it('should validate text block with all variants', () => { + const variants = [ + 'body1', + 'body2', + 'subtitle1', + 'subtitle2', + 'caption', + 'overline' + ] as const + for (const variant of variants) { + const result = journeySimpleBlockSchema.safeParse({ + type: 'text', + text: 'Some text', + variant + }) + expect(result.success).toBe(true) + } }) - it('validates with url and endAt', () => { - expect( - journeySimpleVideoSchema.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - endAt: 120 - }).success - ).toBe(true) + it('should validate button block with navigate action', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'button', + text: 'Next', + action: { kind: 'navigate', cardId: 'card-results' } + }) + expect(result.success).toBe(true) }) - it('validates with url, startAt, and endAt', () => { - expect( - journeySimpleVideoSchema.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: 30, - endAt: 120 - }).success - ).toBe(true) + it('should validate button block with url action', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'button', + text: 'Visit', + action: { kind: 'url', url: 'https://example.com' } + }) + expect(result.success).toBe(true) }) - it('validates with endAt <= startAt (permissive)', () => { - expect( - journeySimpleVideoSchema.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: 120, - endAt: 30 - }).success - ).toBe(true) + it('should validate image block with optional fields omitted', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'image', + src: 'https://example.com/photo.jpg', + alt: 'A photo' + }) + expect(result.success).toBe(true) }) - it('validates with startAt = 0', () => { - expect( - journeySimpleVideoSchema.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: 0 - }).success - ).toBe(true) + it('should validate image block with all fields', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'image', + src: 'https://example.com/photo.jpg', + alt: 'A photo', + width: 800, + height: 600, + blurhash: 'LEHV6nWB2yk8' + }) + expect(result.success).toBe(true) }) - it('fails with negative startAt', () => { - expect( - journeySimpleVideoSchema.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: -10 - }).success - ).toBe(false) + it('should validate video block', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'video', + url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', + startAt: 10, + endAt: 60 + }) + expect(result.success).toBe(true) }) - it('fails with zero endAt', () => { - expect( - journeySimpleVideoSchema.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - endAt: 0 - }).success - ).toBe(false) + it('should validate poll block with 2+ options', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'poll', + options: [ + { text: 'Option A', action: { kind: 'navigate', cardId: 'card-a' } }, + { text: 'Option B', action: { kind: 'navigate', cardId: 'card-b' } } + ] + }) + expect(result.success).toBe(true) }) -}) -describe('journeySimpleVideoSchemaUpdate (strict)', () => { - it('validates with only url', () => { - expect( - journeySimpleVideoSchemaUpdate.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' - }).success - ).toBe(true) + it('should reject poll block with <2 options', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'poll', + options: [ + { text: 'Only one', action: { kind: 'navigate', cardId: 'card-a' } } + ] + }) + expect(result.success).toBe(false) }) - it('validates with url and startAt', () => { - expect( - journeySimpleVideoSchemaUpdate.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: 30 - }).success - ).toBe(true) + it('should validate multiselect block', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'multiselect', + min: 1, + max: 3, + options: ['Red', 'Green', 'Blue'] + }) + expect(result.success).toBe(true) }) - it('validates with url and endAt', () => { - expect( - journeySimpleVideoSchemaUpdate.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - endAt: 120 - }).success - ).toBe(true) + it('should reject multiselect block with <2 options', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'multiselect', + options: ['Only one'] + }) + expect(result.success).toBe(false) }) - it('validates with valid time range (endAt > startAt)', () => { - expect( - journeySimpleVideoSchemaUpdate.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: 30, - endAt: 120 - }).success - ).toBe(true) + it('should validate textInput block with all fields', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'textInput', + label: 'Your name', + inputType: 'name', + placeholder: 'Enter name', + hint: 'First and last name', + required: true + }) + expect(result.success).toBe(true) }) - it('fails with endAt <= startAt (strict)', () => { - const result = journeySimpleVideoSchemaUpdate.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: 120, - endAt: 30 + it('should validate textInput block with defaults', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'textInput', + label: 'Comment' }) - expect(result.success).toBe(false) - if (!result.success) { - expect(result.error.issues[0].message).toMatch( - /endAt must be greater than startAt/ - ) + expect(result.success).toBe(true) + if (result.success && result.data.type === 'textInput') { + expect(result.data.inputType).toBe('freeForm') + expect(result.data.required).toBe(false) } }) - it('fails with endAt = startAt (strict)', () => { - const result = journeySimpleVideoSchemaUpdate.safeParse({ - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: 60, - endAt: 60 + it('should validate spacer block', () => { + const result = journeySimpleBlockSchema.safeParse({ + type: 'spacer', + spacing: 24 }) - expect(result.success).toBe(false) - if (!result.success) { - expect(result.error.issues[0].message).toMatch( - /endAt must be greater than startAt/ - ) - } + expect(result.success).toBe(true) }) }) -// journeySimpleCardSchema tests - -describe('journeySimpleCardSchema (base, permissive)', () => { - const base = { id: 'card-1', x: 0, y: 0 } - - it('validates with button', () => { - expect( - journeySimpleCardSchema.safeParse({ - ...base, - button: { text: 'B', nextCard: 'card-2' } - }).success - ).toBe(true) - }) - - it('validates with poll', () => { - expect( - journeySimpleCardSchema.safeParse({ - ...base, - poll: [{ text: 'A', nextCard: 'card-2' }] - }).success - ).toBe(true) - }) - - it('validates with defaultNextCard', () => { - expect( - journeySimpleCardSchema.safeParse({ ...base, defaultNextCard: 'card-2' }) - .success - ).toBe(true) - }) - - it('validates with video', () => { - expect( - journeySimpleCardSchema.safeParse({ - ...base, - video: { url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' }, - defaultNextCard: 'card-2' - }).success - ).toBe(true) - }) - - it('validates with video and other content (permissive)', () => { - expect( - journeySimpleCardSchema.safeParse({ - ...base, - video: { url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' }, - heading: 'Test', - text: 'Content', - defaultNextCard: 'card-2' - }).success - ).toBe(true) - }) - - it('validates with video but no defaultNextCard (permissive)', () => { - expect( - journeySimpleCardSchema.safeParse({ - ...base, - video: { url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } - }).success - ).toBe(true) - }) - - it('validates with none of button, poll, defaultNextCard (permissive)', () => { - const result = journeySimpleCardSchema.safeParse(base) +describe('journeySimpleCardSchema', () => { + it('should validate card with content array', () => { + const result = journeySimpleCardSchema.safeParse({ + id: 'card-welcome', + content: [ + { type: 'heading', text: 'Welcome' }, + { type: 'text', text: 'Hello world' } + ] + }) expect(result.success).toBe(true) }) -}) -describe('journeySimpleCardSchemaUpdate (strict)', () => { - const base = { id: 'card-1', x: 0, y: 0 } - - it('validates with button', () => { - expect( - journeySimpleCardSchemaUpdate.safeParse({ - ...base, - button: { text: 'B', nextCard: 'card-2' } - }).success - ).toBe(true) - }) - - it('validates with poll', () => { - expect( - journeySimpleCardSchemaUpdate.safeParse({ - ...base, - poll: [{ text: 'A', nextCard: 'card-2' }] - }).success - ).toBe(true) - }) - - it('validates with defaultNextCard', () => { - expect( - journeySimpleCardSchemaUpdate.safeParse({ - ...base, - defaultNextCard: 'card-2' - }).success - ).toBe(true) - }) - - it('validates with video and defaultNextCard (strict)', () => { - expect( - journeySimpleCardSchemaUpdate.safeParse({ - ...base, - video: { url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' }, - defaultNextCard: 'card-2' - }).success - ).toBe(true) - }) - - it('fails with video and other content fields (strict)', () => { - const result = journeySimpleCardSchemaUpdate.safeParse({ - ...base, - video: { url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' }, - heading: 'Test', - defaultNextCard: 'card-2' + it('should validate card with optional x/y omitted', () => { + const result = journeySimpleCardSchema.safeParse({ + id: 'card-welcome', + content: [{ type: 'heading', text: 'Welcome' }] }) - expect(result.success).toBe(false) - if (!result.success) { - expect(result.error.issues[0].message).toMatch( - /If video is present, heading must not be set/ - ) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.x).toBeUndefined() + expect(result.data.y).toBeUndefined() } }) - it('fails with video and button (strict)', () => { - const result = journeySimpleCardSchemaUpdate.safeParse({ - ...base, - video: { url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' }, - button: { text: 'B', nextCard: 'card-2' }, - defaultNextCard: 'card-2' + it('should validate card with backgroundColor', () => { + const result = journeySimpleCardSchema.safeParse({ + id: 'card-dark', + backgroundColor: '#1A1A2E', + content: [{ type: 'heading', text: 'Dark card' }] }) - expect(result.success).toBe(false) - if (!result.success) { - expect(result.error.issues[0].message).toMatch( - /If video is present, button must not be set/ - ) - } + expect(result.success).toBe(true) }) - it('fails with video and poll (strict)', () => { - const result = journeySimpleCardSchemaUpdate.safeParse({ - ...base, - video: { url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' }, - poll: [{ text: 'A', nextCard: 'card-2' }], - defaultNextCard: 'card-2' + it('should validate card with backgroundImage', () => { + const result = journeySimpleCardSchema.safeParse({ + id: 'card-hero', + backgroundImage: { + src: 'https://example.com/bg.jpg', + alt: 'Background' + }, + content: [{ type: 'heading', text: 'Hero' }] + }) + expect(result.success).toBe(true) + }) + + it('should validate card with backgroundVideo', () => { + const result = journeySimpleCardSchema.safeParse({ + id: 'card-video-bg', + backgroundVideo: { + url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', + startAt: 0, + endAt: 30 + }, + content: [{ type: 'heading', text: 'Video BG' }] }) + expect(result.success).toBe(true) + }) +}) + +describe('journeySimpleSchemaUpdate', () => { + const makeJourney = ( + cards: Array<{ + id: string + content: unknown[] + defaultNextCard?: string + backgroundImage?: unknown + backgroundVideo?: unknown + }> + ) => ({ + title: 'Test Journey', + description: 'A test journey', + cards + }) + + it('should validate valid journey with cross-card references', () => { + const result = journeySimpleSchemaUpdate.safeParse( + makeJourney([ + { + id: 'card-welcome', + content: [ + { + type: 'button', + text: 'Go', + action: { kind: 'navigate', cardId: 'card-results' } + } + ], + defaultNextCard: 'card-results' + }, + { + id: 'card-results', + content: [{ type: 'heading', text: 'Results' }] + } + ]) + ) + expect(result.success).toBe(true) + }) + + it('should reject invalid card reference in defaultNextCard', () => { + const result = journeySimpleSchemaUpdate.safeParse( + makeJourney([ + { + id: 'card-welcome', + content: [{ type: 'heading', text: 'Hi' }], + defaultNextCard: 'card-nonexistent' + } + ]) + ) expect(result.success).toBe(false) if (!result.success) { - expect(result.error.issues[0].message).toMatch( - /If video is present, poll must not be set/ - ) + expect( + result.error.issues.some((i) => + i.message.includes('"card-nonexistent" does not exist') + ) + ).toBe(true) } }) - it('fails with video and image (strict)', () => { - const result = journeySimpleCardSchemaUpdate.safeParse({ - ...base, - video: { url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' }, - image: { - src: 'test.jpg', - alt: 'Test', - width: 100, - height: 100, - blurhash: 'test' - }, - defaultNextCard: 'card-2' - }) + it('should reject invalid card reference in button navigate action', () => { + const result = journeySimpleSchemaUpdate.safeParse( + makeJourney([ + { + id: 'card-welcome', + content: [ + { + type: 'button', + text: 'Go', + action: { kind: 'navigate', cardId: 'card-missing' } + } + ] + } + ]) + ) expect(result.success).toBe(false) if (!result.success) { - expect(result.error.issues[0].message).toMatch( - /If video is present, image must not be set/ - ) + expect( + result.error.issues.some((i) => + i.message.includes('"card-missing" does not exist') + ) + ).toBe(true) } }) - it('fails with video but no defaultNextCard (strict)', () => { - const result = journeySimpleCardSchemaUpdate.safeParse({ - ...base, - video: { url: 'https://youtube.com/watch?v=dQw4w9WgXcQ' } - }) + it('should reject invalid card reference in poll option navigate action', () => { + const result = journeySimpleSchemaUpdate.safeParse( + makeJourney([ + { + id: 'card-welcome', + content: [ + { + type: 'poll', + options: [ + { + text: 'A', + action: { kind: 'navigate', cardId: 'card-gone' } + }, + { + text: 'B', + action: { kind: 'navigate', cardId: 'card-welcome' } + } + ] + } + ] + } + ]) + ) expect(result.success).toBe(false) if (!result.success) { - expect(result.error.issues[0].message).toMatch( - /If video is present, defaultNextCard is required/ - ) + expect( + result.error.issues.some((i) => + i.message.includes('"card-gone" does not exist') + ) + ).toBe(true) } }) - it('fails with none of button, poll, defaultNextCard (strict)', () => { - const result = journeySimpleCardSchemaUpdate.safeParse(base) + it('should reject video content with other content blocks', () => { + const result = journeySimpleSchemaUpdate.safeParse( + makeJourney([ + { + id: 'card-video', + content: [ + { type: 'video', url: 'https://youtube.com/watch?v=abc' }, + { type: 'heading', text: 'Extra' } + ] + } + ]) + ) expect(result.success).toBe(false) if (!result.success) { - expect(result.error.issues[0].message).toMatch(/At least one/) + expect( + result.error.issues.some((i) => + i.message.includes('Video content must be the only content block') + ) + ).toBe(true) } }) - it('fails with invalid button (both nextCard and url, strict)', () => { - const result = journeySimpleCardSchemaUpdate.safeParse({ - ...base, - button: { text: 'B', nextCard: 'card-2', url: 'https://b.com' } - }) + it('should reject video content with backgroundImage', () => { + const result = journeySimpleSchemaUpdate.safeParse( + makeJourney([ + { + id: 'card-video', + content: [ + { type: 'video', url: 'https://youtube.com/watch?v=abc' } + ], + backgroundImage: { + src: 'https://example.com/bg.jpg', + alt: 'Background' + } + } + ]) + ) expect(result.success).toBe(false) if (!result.success) { expect( - result.error.issues.some((issue) => /Exactly one/.test(issue.message)) + result.error.issues.some((i) => + i.message.includes( + 'Video content cards cannot have background image or background video' + ) + ) ).toBe(true) } }) - it('fails with invalid poll option (neither nextCard nor url, strict)', () => { - const result = journeySimpleCardSchemaUpdate.safeParse({ - ...base, - poll: [{ text: 'A' }] - }) + it('should reject backgroundImage with backgroundVideo on same card', () => { + const result = journeySimpleSchemaUpdate.safeParse( + makeJourney([ + { + id: 'card-both', + content: [{ type: 'heading', text: 'Hi' }], + backgroundImage: { + src: 'https://example.com/bg.jpg', + alt: 'Background' + }, + backgroundVideo: { + url: 'https://youtube.com/watch?v=abc' + } + } + ]) + ) expect(result.success).toBe(false) if (!result.success) { expect( - result.error.issues.some((issue) => /Exactly one/.test(issue.message)) + result.error.issues.some((i) => + i.message.includes( + 'Card cannot have both backgroundImage and backgroundVideo' + ) + ) ).toBe(true) } }) - it('fails with invalid video timing (endAt <= startAt, strict)', () => { - const result = journeySimpleCardSchemaUpdate.safeParse({ - ...base, - video: { - url: 'https://youtube.com/watch?v=dQw4w9WgXcQ', - startAt: 120, - endAt: 30 - }, - defaultNextCard: 'card-2' - }) + it('should provide helpful error message with valid card IDs listed', () => { + const result = journeySimpleSchemaUpdate.safeParse( + makeJourney([ + { + id: 'card-alpha', + content: [{ type: 'heading', text: 'A' }], + defaultNextCard: 'card-nonexistent' + }, + { + id: 'card-beta', + content: [{ type: 'heading', text: 'B' }] + } + ]) + ) expect(result.success).toBe(false) if (!result.success) { - expect(result.error.issues[0].message).toMatch( - /endAt must be greater than startAt/ + const issue = result.error.issues.find((i) => + i.message.includes('"card-nonexistent" does not exist') ) + expect(issue).toBeDefined() + expect(issue?.message).toContain('card-alpha') + expect(issue?.message).toContain('card-beta') } }) }) diff --git a/libs/shared/ai/src/journeySimpleTypes.ts b/libs/shared/ai/src/journeySimpleTypes.ts index 89b0004b8cd..27b61f5f045 100644 --- a/libs/shared/ai/src/journeySimpleTypes.ts +++ b/libs/shared/ai/src/journeySimpleTypes.ts @@ -1,225 +1,272 @@ import { z } from 'zod' -// --- Poll Option Schemas --- -export const journeySimplePollOptionSchema = z.object({ - text: z.string().describe('The text label for the poll option.'), - nextCard: z - .string() - .optional() - .describe( - 'The id of the card to navigate to if this option is selected. Something like card-1, card-2, etc. Should only provide one of url or nextCard.' - ), - url: z - .string() - .optional() - .describe( - 'A URL to navigate to when the poll option is selected. Should only provide one of url or nextCard.' - ) -}) +// --- Shared URL validation --- +const safeUrl = z + .string() + .url() + .refine( + (u) => u.startsWith('https://') || u.startsWith('http://'), + 'Only https:// and http:// URLs are allowed' + ) -export const journeySimplePollOptionSchemaUpdate = - journeySimplePollOptionSchema.superRefine((data, ctx) => { - const hasNextCard = !!data.nextCard - const hasUrl = !!data.url - if (hasNextCard === hasUrl) { - ctx.addIssue({ - code: 'custom', - message: 'Exactly one of nextCard or url must be provided.' - }) - } +// --- Actions (discriminated union by `kind`) --- +export const journeySimpleActionSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('navigate'), + cardId: z.string().describe('The id of the card to navigate to.') + }), + z.object({ + kind: z.literal('url'), + url: safeUrl.describe('A URL to open in the browser.') + }), + z.object({ + kind: z.literal('email'), + email: z + .string() + .email() + .describe('An email address to open in the mail client.') + }), + z.object({ + kind: z.literal('chat'), + chatUrl: safeUrl.describe('A WhatsApp or chat URL.') + }), + z.object({ + kind: z.literal('phone'), + phone: z.string(), + countryCode: z.string().optional(), + contactAction: z.enum(['call', 'text']).optional() }) +]) -export type JourneySimplePollOption = z.infer< - typeof journeySimplePollOptionSchema -> -export type JourneySimplePollOptionUpdate = z.infer< - typeof journeySimplePollOptionSchemaUpdate -> - -// --- Button Schemas --- -export const journeySimpleButtonSchema = z.object({ - text: z.string().describe('The text label displayed on the button.'), - nextCard: z - .string() - .optional() - .describe( - 'The id of the card to navigate to when the button is pressed. Something like card-1, card-2, etc. Should only provide one of url or nextCard.' - ), - url: z - .string() - .optional() - .describe( - 'A URL to navigate to when the button is pressed. Should only provide one of url or nextCard.' - ) -}) +export type JourneySimpleAction = z.infer -export const journeySimpleButtonSchemaUpdate = - journeySimpleButtonSchema.superRefine((data, ctx) => { - const hasNextCard = !!data.nextCard - const hasUrl = !!data.url - if (hasNextCard === hasUrl) { - ctx.addIssue({ - code: 'custom', - message: 'Exactly one of nextCard or url must be provided.' - }) - } +// --- Content Blocks (discriminated union by `type`) --- +export const journeySimpleBlockSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('heading'), + text: z.string(), + variant: z + .enum(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) + .optional() + .default('h3') + }), + z.object({ + type: z.literal('text'), + text: z.string(), + variant: z + .enum([ + 'body1', + 'body2', + 'subtitle1', + 'subtitle2', + 'caption', + 'overline' + ]) + .optional() + .default('body1') + }), + z.object({ + type: z.literal('button'), + text: z.string().describe('Label displayed on the button.'), + action: journeySimpleActionSchema + }), + z.object({ + type: z.literal('image'), + src: z.string(), + alt: z.string(), + width: z + .number() + .int() + .nonnegative() + .optional() + .describe('Omit — server computes from URL.'), + height: z + .number() + .int() + .nonnegative() + .optional() + .describe('Omit — server computes from URL.'), + blurhash: z.string().optional().describe('Omit — server computes from URL.') + }), + z.object({ + type: z.literal('video'), + url: z.string().describe('YouTube URL.'), + startAt: z.number().int().nonnegative().optional(), + endAt: z.number().int().positive().optional() + }), + z.object({ + type: z.literal('poll'), + gridView: z.boolean().optional().default(false), + options: z + .array( + z.object({ + text: z.string(), + action: journeySimpleActionSchema.optional() + }) + ) + .min(2) + }), + z.object({ + type: z.literal('multiselect'), + min: z.number().int().nonnegative().optional(), + max: z.number().int().positive().optional(), + options: z.array(z.string()).min(2) + }), + z.object({ + type: z.literal('textInput'), + label: z.string(), + inputType: z + .enum(['freeForm', 'name', 'email', 'phone']) + .optional() + .default('freeForm'), + placeholder: z.string().optional(), + hint: z.string().optional(), + required: z.boolean().optional().default(false) + }), + z.object({ + type: z.literal('spacer'), + spacing: z + .number() + .int() + .positive() + .describe('Vertical spacing in pixels.') }) +]) -export type JourneySimpleButton = z.infer -export type JourneySimpleButtonUpdate = z.infer< - typeof journeySimpleButtonSchemaUpdate -> +export type JourneySimpleBlock = z.infer -// --- Image Schema (shared, no update/default distinction needed) --- +// --- Image Schema (for backgroundImage) --- export const journeySimpleImageSchema = z.object({ src: z.string().describe('A URL for the image.'), alt: z.string().describe('Alt text for the image for accessibility.'), - width: z.int().positive().describe('Width of the image in pixels.'), - height: z.int().positive().describe('Height of the image in pixels.'), - blurhash: z - .string() - .describe('A compact representation of a placeholder for the image.') -}) -export type JourneySimpleImage = z.infer - -// --- Video Schema --- -export const journeySimpleVideoSchema = z.object({ - url: z.string().describe('The YouTube video URL.'), - startAt: z + width: z + .number() .int() .nonnegative() .optional() - .describe('Start time in seconds. If not provided, defaults to 0.'), - endAt: z + .describe('Omit — server computes from URL.'), + height: z + .number() .int() - .positive() + .nonnegative() .optional() - .describe( - 'End time in seconds. If not provided, defaults to the video duration.' - ) + .describe('Omit — server computes from URL.'), + blurhash: z.string().optional().describe('Omit — server computes from URL.') }) -export type JourneySimpleVideo = z.infer - -// --- Video Update Schema (with stricter validation) --- -export const journeySimpleVideoSchemaUpdate = - journeySimpleVideoSchema.superRefine((data, ctx) => { - if ( - data.startAt !== undefined && - data.endAt !== undefined && - data.endAt <= data.startAt - ) { - ctx.addIssue({ - code: 'custom', - message: 'endAt must be greater than startAt if both are provided.' - }) - } - }) -export type JourneySimpleVideoUpdate = z.infer< - typeof journeySimpleVideoSchemaUpdate -> -// --- Card Schemas --- +export type JourneySimpleImage = z.infer + +// --- Card Schema --- export const journeySimpleCardSchema = z.object({ - id: z - .string() - .describe('The id of the card. Something like card-1, card-2, etc.'), - x: z.number().describe('The x coordinate for the card layout position.'), - y: z.number().describe('The y coordinate for the card layout position.'), - heading: z - .string() - .optional() - .describe('A heading for the card, if present.'), - text: z.string().optional().describe('The main text content of the card.'), - button: journeySimpleButtonSchema - .optional() - .describe('A button object for this card, if present.'), - poll: z - .array(journeySimplePollOptionSchema) - .optional() - .describe('An array of poll options for this card, if present.'), - image: journeySimpleImageSchema + id: z.string().describe( + 'Unique, semantic identifier. Use descriptive names like "card-welcome" or "card-results". Do NOT use positional names like "card-1".' + ), + x: z + .number() .optional() - .describe('Image object for the card.'), - backgroundImage: journeySimpleImageSchema + .describe('Canvas x position. Omit to auto-layout (index * 300).'), + y: z + .number() .optional() - .describe('Background image object for the card.'), - video: journeySimpleVideoSchema - .optional() - .describe( - 'Video segment for this card, if present. If present, only "id", "video", and (optionally) "defaultNextCard" should be set on this card. All other content fields (heading, text, button, poll, image, backgroundImage, etc.) must be omitted.' - ), - defaultNextCard: z + .describe('Canvas y position. Omit to default to 0.'), + backgroundColor: z .string() .optional() - .describe( - 'The id of the card to navigate to after this card by default. Something like card-1, card-2, etc.' - ) + .describe('Hex color for the card background e.g. "#1A1A2E".'), + backgroundImage: journeySimpleImageSchema.optional(), + backgroundVideo: z + .object({ + url: z.string().describe('YouTube URL.'), + startAt: z.number().int().nonnegative().optional(), + endAt: z.number().int().positive().optional() + }) + .optional(), + content: z + .array(journeySimpleBlockSchema) + .describe('Ordered array of content blocks on this card.'), + defaultNextCard: z.string().optional() }) -export const journeySimpleCardSchemaUpdate = journeySimpleCardSchema - .extend({ - button: journeySimpleButtonSchemaUpdate.optional(), - poll: z.array(journeySimplePollOptionSchemaUpdate).optional(), - video: journeySimpleVideoSchemaUpdate.optional() - }) - .superRefine((data, ctx) => { - if (data.video !== undefined) { - // Enforce only id, video, and defaultNextCard are present - const forbiddenFields = [ - 'heading', - 'text', - 'button', - 'poll', - 'image', - 'backgroundImage' - ] - for (const field of forbiddenFields) { - if ((data as Record)[field] !== undefined) { +export type JourneySimpleCard = z.infer + +// --- Journey Schema --- +export const journeySimpleSchema = z.object({ + title: z.string().describe('The title of the journey.'), + description: z.string().describe('A description of the journey.'), + cards: z + .array(journeySimpleCardSchema) + .describe('An array of cards that make up the journey.') +}) + +export type JourneySimple = z.infer + +// --- Write schema (adds cross-card reference validation + video/background rules) --- +export const journeySimpleSchemaUpdate = journeySimpleSchema.superRefine( + (data, ctx) => { + const cardIds = new Set(data.cards.map((c) => c.id)) + + for (const card of data.cards) { + const cardIndex = data.cards.indexOf(card) + + // Cross-card reference validation + const checkRef = (ref: string | undefined, path: string): void => { + if (ref && !cardIds.has(ref)) { ctx.addIssue({ code: 'custom', - message: `If video is present, ${field} must not be set.` + path: ['cards', cardIndex, path], + message: `Card "${ref}" does not exist. Valid IDs: ${Array.from(cardIds).join(', ')}` + }) + } + } + + checkRef(card.defaultNextCard, 'defaultNextCard') + + for (const block of card.content) { + if (block.type === 'button' && block.action.kind === 'navigate') { + checkRef(block.action.cardId, 'content[button].action.cardId') + } + if (block.type === 'poll') { + block.options.forEach((o, i) => { + if (o.action?.kind === 'navigate') { + checkRef( + o.action.cardId, + `content[poll].options[${i}].action.cardId` + ) + } }) } } - // Enforce defaultNextCard is required for video cards - if (data.defaultNextCard === undefined) { + + // Video/background rules + const hasContentVideo = card.content.some((b) => b.type === 'video') + const hasBgImage = card.backgroundImage != null + const hasBgVideo = card.backgroundVideo != null + + if (hasContentVideo && card.content.length > 1) { ctx.addIssue({ code: 'custom', - message: 'If video is present, defaultNextCard is required.' + path: ['cards', cardIndex, 'content'], + message: 'Video content must be the only content block on a card.' }) } - } else { - // For non-video cards, require at least one navigation option - const hasButton = !!data.button - const hasPoll = Array.isArray(data.poll) && data.poll.length > 0 - const hasDefaultNextCard = !!data.defaultNextCard - if (!hasButton && !hasPoll && !hasDefaultNextCard) { + if (hasContentVideo && (hasBgImage || hasBgVideo)) { ctx.addIssue({ code: 'custom', + path: ['cards', cardIndex], message: - 'At least one of button, poll, or defaultNextCard must be present to provide navigation.' + 'Video content cards cannot have background image or background video.' + }) + } + if (hasBgImage && hasBgVideo) { + ctx.addIssue({ + code: 'custom', + path: ['cards', cardIndex], + message: + 'Card cannot have both backgroundImage and backgroundVideo.' }) } } - }) + } +) -export type JourneySimpleCard = z.infer -export type JourneySimpleCardUpdate = z.infer< - typeof journeySimpleCardSchemaUpdate -> - -// --- Journey Schemas --- -export const journeySimpleSchema = z.object({ - title: z.string().describe('The title of the journey.'), - description: z.string().describe('A description of the journey.'), - cards: z - .array(journeySimpleCardSchema) - .describe('An array of cards that make up the journey.') -}) - -export const journeySimpleSchemaUpdate = journeySimpleSchema.extend({ - cards: z.array(journeySimpleCardSchemaUpdate) -}) - -export type JourneySimple = z.infer export type JourneySimpleUpdate = z.infer diff --git a/package.json b/package.json index 19c86faa986..4345e5cf547 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "private": true, "dependencies": { "@adobe/apollo-link-mutation-queue": "^1.1.0", + "@ai-sdk/anthropic": "^3.0.58", "@ai-sdk/google": "^3.0.43", "@algolia/client-search": "^5.0.0", "@apollo/client": "^3.8.3", @@ -49,6 +50,7 @@ "@casl/prisma": "^1.4.1", "@crowdin/crowdin-api-client": "^1.41.4", "@crowdin/ota-client": "^2.0.0", + "@dagrejs/dagre": "^2.0.4", "@datadog/browser-rum": "^6.14.0", "@datadog/browser-rum-react": "^6.14.0", "@dnd-kit/core": "^6.1.0", diff --git a/pencil-new.pen b/pencil-new.pen new file mode 100644 index 00000000000..4f204307327 --- /dev/null +++ b/pencil-new.pen @@ -0,0 +1,14047 @@ +{ + "version": "2.9", + "children": [ + { + "type": "frame", + "id": "q2oqD", + "x": 1380, + "y": 0, + "name": "2 — Empty State", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "xJUqj", + "name": "AiToolbar", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "MCpx8", + "name": "tb2Left", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "AY8OB", + "name": "tb2Back", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "MdLwd", + "name": "tb2BackLabel", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "dK5Ke", + "name": "tb2Center", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "5JZQ0", + "name": "tb2Title", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "MNnH7", + "name": "tb2Right", + "width": "fit_content(20)", + "height": "fit_content(20)", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "fGni8", + "x": 0, + "y": 0, + "name": "tb2Undo", + "enabled": false, + "width": 20, + "height": 20, + "iconFontName": "undo-2", + "iconFontFamily": "lucide", + "fill": "#CCCCCC" + } + ] + } + ] + }, + { + "type": "frame", + "id": "9Ax2l", + "name": "AiEditorBody", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "9Iwv8", + "name": "AiCanvasPanel", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "o0zLK", + "x": 40, + "y": 100, + "name": "Card-Welcome", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#16213E", + "position": 0.5 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "KVQSv", + "name": "c1spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "SAxOH", + "name": "c1content", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "lAGs8", + "name": "c1heading", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "Rdeo8", + "name": "c1body", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Start your journey here and discover something new.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "OmZCK", + "name": "c1btn", + "width": "fill_container", + "fill": "#C52D3A", + "cornerRadius": 8, + "padding": [ + 10, + 0 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "Oimqv", + "name": "c1btxt", + "fill": "#FFFFFF", + "content": "Get Started", + "fontFamily": "Montserrat", + "fontSize": 11, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "pHirV", + "x": 310, + "y": 100, + "name": "Card-Question", + "clip": true, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "XpqJj", + "name": "c2content", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "BZMQu", + "name": "c2heading", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "ZYqNt", + "name": "c2opt1", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "FYWeq", + "name": "c2opt1t", + "fill": "#26262E", + "content": "Social Media", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "hPDJK", + "name": "c2opt2", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IeEgk", + "name": "c2opt2t", + "fill": "#26262E", + "content": "Friend / Family", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "eqVrL", + "name": "c2opt3", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lZPYE", + "name": "c2opt3t", + "fill": "#26262E", + "content": "Search Engine", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "vk9vP", + "x": 580, + "y": 100, + "name": "Card-ThankYou", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "iLBO2", + "name": "c3spacer", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "tmUfK", + "name": "c3content", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "j217e", + "name": "c3heading", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "87FQP", + "name": "c3body", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "We appreciate you taking the time to share with us.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "ellipse", + "id": "PkQYD", + "x": 205, + "y": 240, + "name": "e1srcDot", + "fill": "#FFFFFF", + "width": 10, + "height": 10, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#C52D3A80" + } + }, + { + "type": "path", + "id": "nBRkM", + "x": 215, + "y": 243, + "name": "e1path", + "geometry": "M0 2l90 0", + "width": 90, + "height": 4, + "stroke": { + "thickness": 2, + "fill": "#6D6D7D30" + } + }, + { + "type": "path", + "id": "vgvOi", + "x": 301, + "y": 239, + "name": "e1arrow", + "geometry": "M0 0l9 6-9 6z", + "fill": "#6D6D7D30", + "width": 9, + "height": 12 + }, + { + "type": "frame", + "id": "4AaaQ", + "x": 218, + "y": 218, + "name": "e1label", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 4 + }, + "gap": 4, + "padding": [ + 4, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "LGnyz", + "name": "e1icon", + "width": 10, + "height": 10, + "iconFontName": "mouse-pointer-click", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + }, + { + "type": "text", + "id": "y1ILG", + "name": "e1txt", + "fill": "#444451", + "content": "Get Started", + "fontFamily": "Open Sans", + "fontSize": 9, + "fontWeight": "600" + } + ] + }, + { + "type": "ellipse", + "id": "9NnFJ", + "x": 475, + "y": 240, + "name": "e2srcDot", + "fill": "#FFFFFF", + "width": 10, + "height": 10, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#6D6D7D80" + } + }, + { + "type": "path", + "id": "XQHkN", + "x": 571, + "y": 239, + "name": "e2arrow", + "geometry": "M0 0l9 6-9 6z", + "fill": "#6D6D7D80", + "width": 9, + "height": 12 + }, + { + "type": "frame", + "id": "BFMhc", + "x": 503, + "y": 218, + "name": "e2label", + "fill": "#FFFFFF", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 4 + }, + "gap": 4, + "padding": [ + 4, + 10 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "MUCGr", + "name": "e2icon", + "width": 10, + "height": 10, + "iconFontName": "arrow-right", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "nqqWc", + "name": "e2txt", + "fill": "#444451", + "content": "Default", + "fontFamily": "Open Sans", + "fontSize": 9, + "fontWeight": "600" + } + ] + }, + { + "type": "rectangle", + "cornerRadius": 1, + "id": "WZP5g", + "x": 485, + "y": 244, + "name": "defRect", + "fill": "#6D6D7D", + "width": 90, + "height": 2 + } + ] + }, + { + "type": "frame", + "id": "r9Oep", + "name": "AiChatPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "LdyWf", + "name": "ChatContent", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 24, + 20, + 0, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "HRlgG", + "name": "emptyIcon", + "width": 40, + "height": 40, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#DEDFE0" + }, + { + "type": "text", + "id": "KRyKU", + "name": "emptyTitle", + "fill": "#26262E", + "content": "AI Journey Editor", + "textAlign": "center", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "4ubnC", + "name": "emptyDesc", + "fill": "#6D6D7D", + "textGrowth": "fixed-width", + "width": 360, + "content": "Describe what you want to build or change.\nThe AI will edit your journey in real time.", + "lineHeight": 1.5, + "textAlign": "center", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "uCYJC", + "name": "chipRow1", + "gap": 8, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "CNv63", + "name": "chip1", + "fill": "#FAFAFA", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 14 + ], + "children": [ + { + "type": "text", + "id": "xJ9T9", + "name": "chip1txt", + "fill": "#444451", + "content": "Build a 5-card onboarding flow", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "u4Ght", + "name": "chip2", + "fill": "#FAFAFA", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 14 + ], + "children": [ + { + "type": "text", + "id": "xCUd0", + "name": "chip2txt", + "fill": "#444451", + "content": "Add a poll to card 2", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "lHl5U", + "name": "chipRow2", + "gap": 8, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "LY7BY", + "name": "chip3", + "fill": "#FAFAFA", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 14 + ], + "children": [ + { + "type": "text", + "id": "68FtP", + "name": "chip3txt", + "fill": "#444451", + "content": "Translate all cards to Spanish", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "tu1Bu", + "name": "chip4", + "fill": "#FAFAFA", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 14 + ], + "children": [ + { + "type": "text", + "id": "qmLsk", + "name": "chip4txt", + "fill": "#444451", + "content": "Add images to each card", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "mp80C", + "name": "modelIndicator", + "width": "fill_container", + "gap": 4, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "r3ly9", + "name": "modelDot", + "fill": "#3AA74A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "8yIEI", + "name": "modelText", + "fill": "#AAAAAA", + "content": "AI-powered · Use your own Claude key →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "vlCWC", + "name": "ChatInputBar", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "2ZBPu", + "name": "inputField", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "9yJdd", + "name": "inputPlaceholder", + "fill": "#AAAAAA", + "content": "Describe what you want to change...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "8eELy", + "name": "sendBtn", + "width": 44, + "height": 44, + "fill": "#CCCCCC", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ExzUc", + "name": "sendIcon", + "width": 20, + "height": 20, + "iconFontName": "arrow-up", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "lYsH4", + "x": 2760, + "y": 0, + "name": "3 — Thinking", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "WOV3o", + "name": "AiToolbar", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "0VAzL", + "name": "tb3l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "1VrRz", + "name": "tb3bi", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "BVg8A", + "name": "tb3bt", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "COXII", + "name": "tb3c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "WnxAS", + "x": 1236, + "y": 14, + "name": "tb3r", + "enabled": false, + "width": 20, + "height": 20, + "iconFontName": "undo-2", + "iconFontFamily": "lucide", + "fill": "#CCCCCC" + } + ] + }, + { + "type": "frame", + "id": "MhSLM", + "name": "Body", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "fAULE", + "name": "Canvas", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "OOGYJ", + "x": 40, + "y": 100, + "name": "Card-Welcome", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#16213E", + "position": 0.5 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "NrQQ1", + "name": "sc3c1s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "JjnKC", + "name": "sc3c1c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "VIwbP", + "name": "sc3c1h", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "sgndd", + "name": "sc3c1b", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Start your journey here and discover something new.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "nHLkZ", + "name": "sc3c1btn", + "width": "fill_container", + "fill": "#C52D3A", + "cornerRadius": 8, + "padding": [ + 10, + 0 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "pHa9A", + "name": "sc3c1bt", + "fill": "#FFFFFF", + "content": "Get Started", + "fontFamily": "Montserrat", + "fontSize": 11, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "JQFDQ", + "x": 310, + "y": 100, + "name": "Card-Question-Selected", + "clip": true, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "outside", + "thickness": 2, + "fill": "#4c9bf8" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#4c9bf830", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 20 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "gVEuE", + "name": "sc3c2c", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "qomMO", + "name": "sc3c2h", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "ODptB", + "name": "sc3c2o1", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "kI2JA", + "name": "sc3c2o1t", + "fill": "#26262E", + "content": "Social Media", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "lbO2B", + "name": "sc3c2o2", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "wUKkj", + "name": "sc3c2o2t", + "fill": "#26262E", + "content": "Friend / Family", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Z8HNw", + "name": "sc3c2o3", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "pBnAb", + "name": "sc3c2o3t", + "fill": "#26262E", + "content": "Search Engine", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "c7leC", + "x": 580, + "y": 100, + "name": "Card-ThankYou", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "AVje9", + "name": "sc3c3s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "3Cl8Y", + "name": "sc3c3c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "ZSYyg", + "name": "sc3c3h", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "vyxl4", + "name": "sc3c3b", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "We appreciate you taking the time to share with us.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "ellipse", + "id": "Jinwz", + "x": 206, + "y": 241, + "name": "s3e1s", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#C52D3A80" + } + }, + { + "type": "path", + "id": "nvbpK", + "x": 214, + "y": 244, + "name": "s3e1p", + "geometry": "M0 2l96 0", + "width": 96, + "height": 4, + "stroke": { + "thickness": 2, + "fill": "#6D6D7D30" + } + }, + { + "type": "path", + "id": "UHkB8", + "x": 306, + "y": 240, + "name": "s3e1a", + "geometry": "M0 0l7 5-7 5z", + "fill": "#6D6D7D30", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "hrGDu", + "x": 225, + "y": 225, + "name": "s3e1l", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "POZte", + "name": "s3e1i", + "width": 9, + "height": 9, + "iconFontName": "mouse-pointer-click", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + }, + { + "type": "text", + "id": "prMUS", + "name": "s3e1t", + "fill": "#444451", + "content": "Get Started", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "ellipse", + "id": "OuCdP", + "x": 476, + "y": 241, + "name": "s3e2s", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#6D6D7D80" + } + }, + { + "type": "path", + "id": "iyq0C", + "x": 576, + "y": 240, + "name": "s3e2a", + "geometry": "M0 0l7 5-7 5z", + "fill": "#6D6D7D80", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "r4fhL", + "x": 505, + "y": 225, + "name": "s3e2l", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "pcEaZ", + "name": "s3e2i", + "width": 9, + "height": 9, + "iconFontName": "arrow-right", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "BckUx", + "name": "s3e2t", + "fill": "#888888", + "content": "Default", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "line", + "id": "JTXtN", + "x": 484, + "y": 245, + "name": "s3defLine", + "width": 96, + "height": 0, + "stroke": { + "thickness": 2, + "cap": "round", + "fill": "#6D6D7D80" + } + } + ] + }, + { + "type": "frame", + "id": "4WnR5", + "name": "ChatPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "KW8Gz", + "name": "Messages", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 20, + 20, + 0, + 20 + ], + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "5hFMk", + "name": "userMsg", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "O1qIG", + "name": "userBubble", + "fill": "#C52D3A", + "cornerRadius": [ + 12, + 12, + 4, + 12 + ], + "layout": "vertical", + "gap": 6, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "frame", + "id": "wejRY", + "name": "ctxInMsg", + "fill": "#FFFFFF20", + "cornerRadius": 4, + "gap": 4, + "padding": [ + 3, + 6 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "B3enE", + "name": "ctxMsgIcon", + "width": 10, + "height": 10, + "iconFontName": "layers", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "HswXc", + "name": "ctxMsgTxt", + "fill": "#FFFFFF", + "content": "card-question", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "500" + } + ] + }, + { + "type": "text", + "id": "q5xBC", + "name": "userText", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": 260, + "content": "Add a text input asking for the\nuser's email", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "JVojY", + "name": "aiThinking", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "W7Orn", + "name": "aiAvatar", + "width": 28, + "height": 28, + "fill": "#F5F5F5", + "cornerRadius": 14, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "5IlBP", + "name": "aiAvatarIcon", + "width": 14, + "height": 14, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "aAObF", + "name": "thinkingDots", + "fill": "#F5F5F5", + "cornerRadius": [ + 12, + 12, + 12, + 4 + ], + "gap": 4, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "2TYdx", + "name": "dot1", + "fill": "#AAAAAA", + "width": 8, + "height": 8 + }, + { + "type": "ellipse", + "id": "UEynz", + "name": "dot2", + "fill": "#CCCCCC", + "width": 8, + "height": 8 + }, + { + "type": "ellipse", + "id": "a96wh", + "name": "dot3", + "fill": "#DEDFE0", + "width": 8, + "height": 8 + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "eLTzF", + "name": "modelInd3", + "width": "fill_container", + "gap": 4, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "mamq7", + "name": "mDot3", + "fill": "#3AA74A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "l23Ct", + "name": "mTxt3", + "fill": "#AAAAAA", + "content": "AI-powered · Use your own Claude key →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "T1LC3", + "name": "InputBar", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "cSZNd", + "name": "field3", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "duLOS", + "x": 100.5, + "y": 0, + "name": "fieldTxt3", + "enabled": false, + "fill": "#AAAAAA", + "content": "Describe what you want to change...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "6iVsF", + "name": "ctxRow", + "width": "fill_container", + "gap": 6, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RfLto", + "name": "ctxPill", + "fill": "#EBF3FE", + "cornerRadius": 6, + "gap": 4, + "padding": [ + 4, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "yJhQ5", + "name": "ctxIcon", + "width": 12, + "height": 12, + "iconFontName": "layers", + "iconFontFamily": "lucide", + "fill": "#4c9bf8" + }, + { + "type": "text", + "id": "CpdUM", + "name": "ctxLabel", + "fill": "#4c9bf8", + "content": "card-question", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "XlPlJ", + "name": "ctxX", + "width": 10, + "height": 10, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "#4c9bf880" + } + ] + } + ] + }, + { + "type": "frame", + "id": "OpmVM", + "name": "inputRow", + "width": "fill_container", + "padding": [ + 4, + 12, + 10, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "wKcfd", + "name": "inputTxt", + "fill": "#26262E", + "content": "Add a text input asking for the user's email", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "hDObx", + "name": "stopBtn", + "width": 44, + "height": 44, + "fill": "#B62D1C", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "SCanu", + "name": "stopIcon", + "width": 16, + "height": 16, + "iconFontName": "square", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "lhly8", + "x": 0, + "y": 920, + "name": "4 — Streaming + Plan", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "jGZjK", + "name": "Toolbar", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "qlWMm", + "name": "tb4l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "AWlGO", + "name": "tb4bi", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "FLhTp", + "name": "tb4bt", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "TCJ6v", + "name": "tb4c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "SOtF1", + "x": 1236, + "y": 14, + "name": "tb4r", + "enabled": false, + "width": 20, + "height": 20, + "iconFontName": "undo-2", + "iconFontFamily": "lucide", + "fill": "#CCCCCC" + } + ] + }, + { + "type": "frame", + "id": "AzsHx", + "name": "bd4", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "TTgWK", + "name": "cv4", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "EXiEs", + "x": 40, + "y": 100, + "name": "s4ca", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Hkas3", + "name": "s4cas", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "ZUQSv", + "name": "s4cac", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "QzUF4", + "name": "s4cah", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "nbVIk", + "name": "s4cab", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Start your journey here and discover something new.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "YYmmU", + "name": "s4cabt", + "width": "fill_container", + "fill": "#C52D3A", + "cornerRadius": 8, + "padding": [ + 10, + 0 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "RiMbk", + "name": "s4cabtx", + "fill": "#FFFFFF", + "content": "Get Started", + "fontFamily": "Montserrat", + "fontSize": 11, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zaodH", + "x": 310, + "y": 100, + "name": "s4cb", + "clip": true, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "outside", + "thickness": 2, + "fill": "#4c9bf8" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#4c9bf830", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 20 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "aynDM", + "name": "s4cbc", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "BINqB", + "name": "s4cbh", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "Rqbqi", + "name": "s4cbo1", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "bTdmi", + "name": "s4cbo1t", + "fill": "#26262E", + "content": "Social Media", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "YOMYu", + "name": "s4cbo2", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "w6d5E", + "name": "s4cbo2t", + "fill": "#26262E", + "content": "Friend / Family", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "k5y52", + "name": "s4cbo3", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "fYFmO", + "name": "s4cbo3t", + "fill": "#26262E", + "content": "Search Engine", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "FgPTe", + "x": 580, + "y": 100, + "name": "s4cc", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "tnC5C", + "name": "s4ccs", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "0qrXf", + "name": "s4ccc", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "iX8jX", + "name": "s4cch", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "0omQB", + "name": "s4ccb", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "We appreciate you taking the time to share with us.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "ellipse", + "id": "iJ1Hq", + "x": 206, + "y": 241, + "name": "s4e1s", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#C52D3A80" + } + }, + { + "type": "path", + "id": "H7w2N", + "x": 214, + "y": 244, + "name": "s4e1p", + "geometry": "M0 2l96 0", + "width": 96, + "height": 4, + "stroke": { + "thickness": 2, + "fill": "#6D6D7D30" + } + }, + { + "type": "path", + "id": "umsDN", + "x": 306, + "y": 240, + "name": "s4e1a", + "geometry": "M0 0l7 5-7 5z", + "fill": "#6D6D7D30", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "80GdE", + "x": 225, + "y": 225, + "name": "s4e1l", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "q1q94", + "name": "s4e1i", + "width": 9, + "height": 9, + "iconFontName": "mouse-pointer-click", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + }, + { + "type": "text", + "id": "liTdr", + "name": "s4e1t", + "fill": "#444451", + "content": "Get Started", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "ellipse", + "id": "TjIJX", + "x": 476, + "y": 241, + "name": "s4e2s", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#6D6D7D80" + } + }, + { + "type": "path", + "id": "HP49x", + "x": 576, + "y": 240, + "name": "s4e2a", + "geometry": "M0 0l7 5-7 5z", + "fill": "#6D6D7D80", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "K6Lp1", + "x": 505, + "y": 225, + "name": "s4e2l", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "VxxI7", + "name": "s4e2i", + "width": 9, + "height": 9, + "iconFontName": "arrow-right", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "exVHw", + "name": "s4e2t", + "fill": "#888888", + "content": "Default", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "line", + "id": "IaPDH", + "x": 484, + "y": 245, + "name": "s4defLine", + "width": 96, + "height": 0, + "stroke": { + "thickness": 2, + "cap": "round", + "fill": "#6D6D7D80" + } + } + ] + }, + { + "type": "frame", + "id": "qhihd", + "name": "ChatPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "1QzZW", + "name": "Messages", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 20, + 20, + 0, + 20 + ], + "children": [ + { + "type": "frame", + "id": "mVMyd", + "name": "uMsg4", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "CmxxC", + "name": "uBub4", + "fill": "#C52D3A", + "cornerRadius": [ + 12, + 12, + 4, + 12 + ], + "layout": "vertical", + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "WGdBU", + "name": "uTxt4", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": 260, + "content": "Add a text input asking for the\nuser's email to card 2", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Wgp1m", + "name": "aiRow4", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "6R23d", + "name": "aiAv4", + "width": 28, + "height": 28, + "fill": "#F5F5F5", + "cornerRadius": 14, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "jjJpr", + "name": "aiAvI4", + "width": 14, + "height": 14, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "4nOSN", + "name": "aiContent4", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "nQeV3", + "name": "aiText4", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "I'll add an email input field to the second card. Let me plan the changes...", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "GRD9Y", + "name": "toolCard", + "width": "fill_container", + "fill": "#F5F5F5", + "cornerRadius": 8, + "gap": 8, + "padding": [ + 8, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "kGEH9", + "name": "toolIcon", + "width": 14, + "height": 14, + "iconFontName": "search", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "IRAFg", + "name": "toolText", + "fill": "#6D6D7D", + "content": "Validating journey structure...", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "42nKh", + "name": "toolCheck", + "width": 14, + "height": 14, + "iconFontName": "circle-check", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + } + ] + }, + { + "type": "frame", + "id": "JxEXe", + "name": "PlanCard", + "clip": true, + "width": "fill_container", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "dgRSn", + "name": "planHeader", + "width": "fill_container", + "fill": "#F5F5F5", + "gap": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "bUIS2", + "name": "planTitle", + "fill": "#26262E", + "content": "Execution Plan", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "dE98j", + "name": "planBadge", + "fill": "#F0720C", + "cornerRadius": 10, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "tcrlr", + "name": "planBadgeTxt", + "fill": "#FFFFFF", + "content": "2 ops", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "x46vU", + "name": "planOps", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "fmtvR", + "name": "op1", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#F0F0F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "Z6myM", + "name": "op1status", + "fill": "#EFEFEF", + "width": 16, + "height": 16, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#F0720C" + } + }, + { + "type": "text", + "id": "QOXrn", + "name": "op1txt", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Add email input to \"How did you hear?\"", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "94tOt", + "name": "op2", + "width": "fill_container", + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "oQnWk", + "name": "op2status", + "fill": "#EFEFEF", + "width": 16, + "height": 16, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#DEDFE0" + } + }, + { + "type": "text", + "id": "4WiQT", + "name": "op2txt", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Connect \"How did you hear?\" → \"Thank You\"", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "MeQ1x", + "name": "modelInd4", + "width": "fill_container", + "gap": 4, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "VFqmN", + "name": "mDot4", + "fill": "#3AA74A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "UFi0O", + "name": "mTxt4", + "fill": "#AAAAAA", + "content": "AI-powered · Use your own Claude key →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "t9HvA", + "name": "inBar4", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "uo4VG", + "name": "fld4", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "hLQ9x", + "name": "fldTxt4", + "fill": "#AAAAAA", + "content": "Describe what you want to change...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ccAeC", + "name": "stpBtn4", + "width": 44, + "height": 44, + "fill": "#B62D1C", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "9GTVe", + "name": "stpIcn4", + "width": 16, + "height": 16, + "iconFontName": "square", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "NQPor", + "x": 1380, + "y": 920, + "name": "5 — Executing", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Z9qwH", + "name": "Toolbar", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "RcCpV", + "name": "tb5l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "vXwtg", + "name": "tb5bi", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "VqiDl", + "name": "tb5bt", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "wkX30", + "name": "tb5c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "Ia0OB", + "x": 1236, + "y": 14, + "name": "tb5r", + "enabled": false, + "width": 20, + "height": 20, + "iconFontName": "undo-2", + "iconFontFamily": "lucide", + "fill": "#CCCCCC" + } + ] + }, + { + "type": "frame", + "id": "cKlb2", + "name": "bd5", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "JTIFW", + "name": "cv5", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "HMR7K", + "x": 40, + "y": 100, + "name": "s5ca", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "0oq7I", + "name": "s5cas", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "ZPiQ4", + "name": "s5cac", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "dBIhV", + "name": "s5cah", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "juBhQ", + "name": "s5cab", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Start your journey here and discover something new.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "2h5U8", + "name": "s5cabt", + "width": "fill_container", + "fill": "#C52D3A", + "cornerRadius": 8, + "padding": [ + 10, + 0 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "IXHtD", + "name": "s5cabtx", + "fill": "#FFFFFF", + "content": "Get Started", + "fontFamily": "Montserrat", + "fontSize": 11, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "pvAkI", + "x": 310, + "y": 100, + "name": "Card-Shimmer", + "clip": true, + "width": 170, + "height": 290, + "cornerRadius": 16, + "stroke": { + "align": "outside", + "thickness": 2, + "fill": "#C52D3A" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#C52D3A25", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 20 + }, + "layout": "none", + "children": [ + { + "type": "frame", + "id": "Hc7Sm", + "x": 0, + "y": 0, + "name": "s5cbBg", + "opacity": 0.3, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "layout": "vertical", + "gap": 12, + "padding": [ + 24, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "XIV9L", + "name": "s5cbH", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "HBLx4", + "name": "s5cbO1", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "62X9p", + "name": "s5cbO1t", + "fill": "#26262E", + "content": "Social Media", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "SFTRV", + "x": 0, + "y": 0, + "name": "s5shimmer", + "opacity": 0.75, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "layout": "vertical", + "gap": 14, + "padding": [ + 24, + 16, + 20, + 16 + ], + "children": [ + { + "type": "rectangle", + "cornerRadius": 4, + "id": "IqSEj", + "name": "sh1", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 90, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#EFEFEF", + "position": 0 + }, + { + "color": "#E0E0E0", + "position": 0.4 + }, + { + "color": "#EFEFEF", + "position": 1 + } + ] + }, + "width": 120, + "height": 14 + }, + { + "type": "rectangle", + "cornerRadius": 4, + "id": "D1Bvb", + "name": "sh2", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 90, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#EFEFEF", + "position": 0 + }, + { + "color": "#E0E0E0", + "position": 0.4 + }, + { + "color": "#EFEFEF", + "position": 1 + } + ] + }, + "width": 90, + "height": 10 + }, + { + "type": "frame", + "id": "s4bVo", + "name": "sh3", + "width": "fill_container", + "height": 12 + }, + { + "type": "rectangle", + "cornerRadius": 8, + "id": "6iFZI", + "name": "sh4", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 90, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#EFEFEF", + "position": 0 + }, + { + "color": "#DEDFE0", + "position": 0.4 + }, + { + "color": "#EFEFEF", + "position": 1 + } + ] + }, + "width": "fill_container", + "height": 32 + }, + { + "type": "rectangle", + "cornerRadius": 8, + "id": "jyht5", + "name": "sh5", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 90, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#EFEFEF", + "position": 0 + }, + { + "color": "#DEDFE0", + "position": 0.4 + }, + { + "color": "#EFEFEF", + "position": 1 + } + ] + }, + "width": "fill_container", + "height": 32 + }, + { + "type": "rectangle", + "cornerRadius": 8, + "id": "nX5kf", + "name": "sh6", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 90, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#EFEFEF", + "position": 0 + }, + { + "color": "#DEDFE0", + "position": 0.4 + }, + { + "color": "#EFEFEF", + "position": 1 + } + ] + }, + "width": "fill_container", + "height": 32 + }, + { + "type": "frame", + "id": "C0GXx", + "name": "sh7", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "rectangle", + "cornerRadius": 4, + "id": "jqSF4", + "name": "sh8", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 90, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#EFEFEF", + "position": 0 + }, + { + "color": "#E0E0E0", + "position": 0.4 + }, + { + "color": "#EFEFEF", + "position": 1 + } + ] + }, + "width": 60, + "height": 8 + }, + { + "type": "rectangle", + "cornerRadius": 8, + "id": "NiOt5", + "name": "sh9", + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 90, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#EFEFEF", + "position": 0 + }, + { + "color": "#DEDFE0", + "position": 0.4 + }, + { + "color": "#EFEFEF", + "position": 1 + } + ] + }, + "width": "fill_container", + "height": 32 + } + ] + } + ] + }, + { + "type": "frame", + "id": "tkX7z", + "x": 450, + "y": 106, + "name": "s5spin", + "width": 24, + "height": 24, + "fill": "#C52D3A", + "cornerRadius": 12, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#C52D3A50", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 6 + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "baU96", + "name": "s5spi", + "width": 14, + "height": 14, + "iconFontName": "loader", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + }, + { + "type": "frame", + "id": "8hcFe", + "x": 580, + "y": 100, + "name": "s5cc", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "jijQN", + "name": "s5ccs", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "Le7Am", + "name": "s5ccc", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "DFcgn", + "name": "s5cch", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "WFcw2", + "name": "s5ccb", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "We appreciate you taking the time to share with us.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "ellipse", + "id": "pNOpl", + "x": 206, + "y": 241, + "name": "s5e1s", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#C52D3A80" + } + }, + { + "type": "path", + "id": "1lpHo", + "x": 214, + "y": 244, + "name": "s5e1p", + "geometry": "M0 2l96 0", + "width": 96, + "height": 4, + "stroke": { + "thickness": 2, + "fill": "#6D6D7D30" + } + }, + { + "type": "path", + "id": "FJzpT", + "x": 306, + "y": 240, + "name": "s5e1a", + "geometry": "M0 0l7 5-7 5z", + "fill": "#6D6D7D30", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "curOO", + "x": 225, + "y": 225, + "name": "s5e1l", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Ie3bN", + "name": "s5e1i", + "width": 9, + "height": 9, + "iconFontName": "mouse-pointer-click", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + }, + { + "type": "text", + "id": "HHOd7", + "name": "s5e1t", + "fill": "#444451", + "content": "Get Started", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "ellipse", + "id": "oiPbZ", + "x": 476, + "y": 241, + "name": "s5e2s", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#6D6D7D80" + } + }, + { + "type": "path", + "id": "kmDHW", + "x": 576, + "y": 240, + "name": "s5e2a", + "geometry": "M0 0l7 5-7 5z", + "fill": "#6D6D7D80", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "Iucqj", + "x": 505, + "y": 225, + "name": "s5e2l", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "UMmvw", + "name": "s5e2i", + "width": 9, + "height": 9, + "iconFontName": "arrow-right", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "aNVaj", + "name": "s5e2t", + "fill": "#888888", + "content": "Default", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "line", + "id": "IUKWf", + "x": 484, + "y": 245, + "name": "s5defLine", + "width": 96, + "height": 0, + "stroke": { + "thickness": 2, + "cap": "round", + "fill": "#6D6D7D80" + } + } + ] + }, + { + "type": "frame", + "id": "ccnpB", + "name": "ChatPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "tOls8", + "name": "Messages", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 20, + 20, + 0, + 20 + ], + "children": [ + { + "type": "frame", + "id": "yStmU", + "name": "um5", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "TS7ZW", + "name": "ub5", + "fill": "#C52D3A", + "cornerRadius": [ + 12, + 12, + 4, + 12 + ], + "layout": "vertical", + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "PxtPh", + "name": "ut5", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": 260, + "content": "Add a text input asking for the\nuser's email to card 2", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "LGupr", + "name": "ar5", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "11iBy", + "name": "av5", + "width": 28, + "height": 28, + "fill": "#F5F5F5", + "cornerRadius": 14, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "EekWW", + "name": "avi5", + "width": 14, + "height": 14, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "UtWKr", + "name": "ac5", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "tsw2N", + "name": "at5", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "I'll add an email input field to the second card.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "sMhuw", + "name": "PlanCard", + "clip": true, + "width": "fill_container", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "tjEXw", + "name": "ph5", + "width": "fill_container", + "fill": "#F5F5F5", + "gap": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mNSJn", + "name": "pt5", + "fill": "#26262E", + "content": "Execution Plan", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "fzpRJ", + "name": "pb5", + "fill": "#C52D3A", + "cornerRadius": 10, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "h258Q", + "name": "pbt5", + "fill": "#FFFFFF", + "content": "Running", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "cQyKb", + "name": "po5", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "rKydd", + "name": "op5a", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#F0F0F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "6eLFj", + "name": "op5aIcon", + "width": 16, + "height": 16, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "ZnTeQ", + "name": "op5aTxt", + "fill": "#6D6D7D", + "content": "Add email input to \"How did you hear?\"", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ZeHru", + "name": "op5b", + "width": "fill_container", + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "BzXn2", + "name": "op5bDot", + "fill": "#EFEFEF", + "width": 16, + "height": 16, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#C52D3A" + } + }, + { + "type": "text", + "id": "2QtVg", + "name": "op5bTxt", + "fill": "#26262E", + "content": "Connect \"How did you hear?\" → \"Thank You\"", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "icon_font", + "id": "wNzRL", + "name": "op5bSpin", + "width": 14, + "height": 14, + "iconFontName": "loader", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "YYet9", + "name": "mi5", + "width": "fill_container", + "gap": 4, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "EVKZY", + "name": "md5", + "fill": "#3AA74A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "asEK9", + "name": "mt5", + "fill": "#AAAAAA", + "content": "AI-powered · Use your own Claude key →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "wSANF", + "name": "ib5", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "oJnuA", + "name": "if5", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "vct5F", + "name": "ift5", + "fill": "#AAAAAA", + "content": "Describe what you want to change...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IYfNE", + "name": "sb5", + "width": 44, + "height": 44, + "fill": "#B62D1C", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "wehbK", + "name": "si5", + "width": 16, + "height": 16, + "iconFontName": "square", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "kefW1", + "x": 2760, + "y": 920, + "name": "6 — Completed", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "ZlLiE", + "name": "Toolbar", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "q3QGH", + "name": "tb6l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "1rOko", + "name": "tb6bi", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "Od70q", + "name": "tb6bt", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "S9CAd", + "name": "tb6c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "uTYAQ", + "x": 1236, + "y": 14, + "name": "tb6r", + "enabled": false, + "width": 20, + "height": 20, + "iconFontName": "undo-2", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "eu8gP", + "name": "bd6", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "WyJgs", + "name": "cv6", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "WE9RZ", + "x": 40, + "y": 100, + "name": "s6ca", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Gjrjz", + "name": "s6cas", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "BsgmR", + "name": "s6cac", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "WA3jO", + "name": "s6cah", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "FW2ks", + "name": "s6cab", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Start your journey here and discover something new.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "1lDsR", + "name": "s6cabt", + "width": "fill_container", + "fill": "#C52D3A", + "cornerRadius": 8, + "padding": [ + 10, + 0 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "x0exY", + "name": "s6cabtx", + "fill": "#FFFFFF", + "content": "Get Started", + "fontFamily": "Montserrat", + "fontSize": 11, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "03aNL", + "x": 310, + "y": 100, + "name": "s6cb", + "clip": true, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "outside", + "thickness": 2, + "fill": "#3AA74A" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#3AA74A25", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "tqeMh", + "name": "s6cbc", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "PLbN8", + "name": "s6cbh", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "p886z", + "name": "s6cbo1", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "gIwyu", + "name": "s6cbo1t", + "fill": "#26262E", + "content": "Social Media", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "FxrBP", + "name": "s6cbo2", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "CuwYo", + "name": "s6cbo2t", + "fill": "#26262E", + "content": "Friend / Family", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "KVuqI", + "name": "s6eml", + "fill": "#6D6D7D", + "content": "Email", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "GTSTn", + "name": "s6emli", + "width": "fill_container", + "fill": "#FAFAFA", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 12 + ], + "children": [ + { + "type": "text", + "id": "yX9rx", + "name": "s6emlp", + "fill": "#AAAAAA", + "content": "you@example.com", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Wbonb", + "x": 580, + "y": 100, + "name": "s6cc", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "lbBji", + "name": "s6ccs", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "kxtLi", + "name": "s6ccc", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "FRAaz", + "name": "s6cch", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "PP5de", + "name": "s6ccb", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "We appreciate you taking the time to share with us.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "ellipse", + "id": "q0Qn5", + "x": 206, + "y": 241, + "name": "s6e1s", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#C52D3A80" + } + }, + { + "type": "path", + "id": "b55H6", + "x": 214, + "y": 244, + "name": "s6e1p", + "geometry": "M0 2l96 0", + "width": 96, + "height": 4, + "stroke": { + "thickness": 2, + "fill": "#6D6D7D30" + } + }, + { + "type": "path", + "id": "FDni6", + "x": 306, + "y": 240, + "name": "s6e1a", + "geometry": "M0 0l7 5-7 5z", + "fill": "#6D6D7D30", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "QAILU", + "x": 225, + "y": 225, + "name": "s6e1l", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "f0GRM", + "name": "s6e1i", + "width": 9, + "height": 9, + "iconFontName": "mouse-pointer-click", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + }, + { + "type": "text", + "id": "36ExD", + "name": "s6e1t", + "fill": "#444451", + "content": "Get Started", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "ellipse", + "id": "LeoP9", + "x": 476, + "y": 241, + "name": "s6e2s", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#6D6D7D80" + } + }, + { + "type": "path", + "id": "ONVLo", + "x": 576, + "y": 240, + "name": "s6e2a", + "geometry": "M0 0l7 5-7 5z", + "fill": "#6D6D7D80", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "PYwoe", + "x": 505, + "y": 225, + "name": "s6e2l", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "fPfXO", + "name": "s6e2i", + "width": 9, + "height": 9, + "iconFontName": "arrow-right", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "S7YsN", + "name": "s6e2t", + "fill": "#888888", + "content": "Default", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "line", + "id": "66TaY", + "x": 484, + "y": 245, + "name": "s6defLine", + "width": 96, + "height": 0, + "stroke": { + "thickness": 2, + "cap": "round", + "fill": "#6D6D7D80" + } + } + ] + }, + { + "type": "frame", + "id": "euSG3", + "name": "ChatPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Ytb0C", + "name": "Messages", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 20, + 20, + 0, + 20 + ], + "children": [ + { + "type": "frame", + "id": "b6kZW", + "name": "um6", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "GbDQT", + "name": "ub6", + "fill": "#C52D3A", + "cornerRadius": [ + 12, + 12, + 4, + 12 + ], + "layout": "vertical", + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "qeA7n", + "name": "ut6", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": 260, + "content": "Add a text input asking for the\nuser's email to card 2", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "pj5aU", + "layoutPosition": "absolute", + "x": 248, + "y": 38, + "name": "undoOnBubble6", + "fill": "#FFFFFF", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000015", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 6 + }, + "gap": 4, + "padding": [ + 4, + 6 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "IRFjK", + "name": "undoObi6", + "width": 11, + "height": 11, + "iconFontName": "undo-2", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "fEV05", + "name": "ar6", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "IcSzO", + "name": "av6", + "width": 28, + "height": 28, + "fill": "#F5F5F5", + "cornerRadius": 14, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "oFYc2", + "name": "avi6", + "width": 14, + "height": 14, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "v4eUP", + "name": "ac6", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "ldvNL", + "name": "at6", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Done! I added an email input field to the \"How did you hear about us?\" card. The field is labeled \"Email\" with a placeholder.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "fohHu", + "name": "PlanCard", + "clip": true, + "width": "fill_container", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Sru1k", + "name": "ph6", + "width": "fill_container", + "fill": "#F5F5F5", + "gap": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "Jo3XX", + "name": "pt6", + "fill": "#26262E", + "content": "Execution Plan", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "619yy", + "name": "pb6", + "fill": "#3AA74A", + "cornerRadius": 10, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "2oljK", + "name": "pbt6", + "fill": "#FFFFFF", + "content": "Complete", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "QyIVw", + "name": "po6", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "iZKOn", + "name": "op6a", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#F0F0F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "QRXJO", + "name": "op6aI", + "width": 16, + "height": 16, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "evdUk", + "name": "op6aT", + "fill": "#6D6D7D", + "content": "Add email input to \"How did you hear?\"", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "db2WB", + "name": "op6b", + "width": "fill_container", + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "xNk2T", + "name": "op6bI", + "width": 16, + "height": 16, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "HlsmH", + "name": "op6bT", + "fill": "#6D6D7D", + "content": "Connect \"How did you hear?\" → \"Thank You\"", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "KERBD", + "name": "mi6", + "width": "fill_container", + "gap": 4, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "p0510", + "name": "md6", + "fill": "#3AA74A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "QWrmS", + "name": "mt6", + "fill": "#AAAAAA", + "content": "AI-powered · Use your own Claude key →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "j8jse", + "name": "ib6", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "ebbHk", + "name": "if6", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "IXHU0", + "name": "ift6", + "fill": "#AAAAAA", + "content": "Describe what you want to change...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "CGitk", + "name": "snd6", + "width": 44, + "height": 44, + "fill": "#C52D3A", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "XYJYE", + "name": "sni6", + "width": 20, + "height": 20, + "iconFontName": "arrow-up", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "w3Pvz", + "x": 0, + "y": 1840, + "name": "7 — Confirmation", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Uc33O", + "name": "Toolbar", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "wMgsV", + "name": "tb7l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "gUAHi", + "name": "tb7bi", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "Z5kc0", + "name": "tb7bt", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "CrZRa", + "name": "tb7c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "Ps3Jc", + "x": 1236, + "y": 14, + "name": "tb7r", + "enabled": false, + "width": 20, + "height": 20, + "iconFontName": "undo-2", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "o72Gx", + "name": "bd7", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "bsoMh", + "name": "cv7", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "QXVEA", + "x": 40, + "y": 100, + "name": "s7ca", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "NJ9Sb", + "name": "s7cas", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "tT0SE", + "name": "s7cac", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "s6EJr", + "name": "s7cah", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "2YX1I", + "name": "s7cab", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Start your journey here and discover something new.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "QHgql", + "name": "s7cabt", + "width": "fill_container", + "fill": "#C52D3A", + "cornerRadius": 8, + "padding": [ + 10, + 0 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "IrSou", + "name": "s7cabtx", + "fill": "#FFFFFF", + "content": "Get Started", + "fontFamily": "Montserrat", + "fontSize": 11, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "J12wl", + "x": 310, + "y": 100, + "name": "s7cb", + "clip": true, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "O7DEC", + "name": "s7cbc", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "dN2Vm", + "name": "s7cbh", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "xKCVg", + "name": "s7cbo1", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "unwM0", + "name": "s7cbo1t", + "fill": "#26262E", + "content": "Social Media", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "TriKI", + "x": 580, + "y": 100, + "name": "s7cc", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "D0fkJ", + "name": "s7ccs", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "KTu0k", + "name": "s7ccc", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "smuvw", + "name": "s7cch", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "ellipse", + "id": "spMDD", + "x": 206, + "y": 241, + "name": "s7e1s", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#C52D3A80" + } + }, + { + "type": "path", + "id": "9aoPY", + "x": 214, + "y": 244, + "name": "s7e1p", + "geometry": "M0 2l96 0", + "width": 96, + "height": 4, + "stroke": { + "thickness": 2, + "fill": "#6D6D7D30" + } + }, + { + "type": "path", + "id": "9G1YV", + "x": 306, + "y": 240, + "name": "s7e1a", + "geometry": "M0 0l7 5-7 5z", + "fill": "#6D6D7D30", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "97raK", + "x": 225, + "y": 225, + "name": "s7e1l", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "zf2bF", + "name": "s7e1i", + "width": 9, + "height": 9, + "iconFontName": "mouse-pointer-click", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + }, + { + "type": "text", + "id": "hw63J", + "name": "s7e1t", + "fill": "#444451", + "content": "Get Started", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "ellipse", + "id": "US0Q6", + "x": 476, + "y": 241, + "name": "s7e2s", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#6D6D7D80" + } + }, + { + "type": "path", + "id": "N7zuF", + "x": 576, + "y": 240, + "name": "s7e2a", + "geometry": "M0 0l7 5-7 5z", + "fill": "#6D6D7D80", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "6wvhs", + "x": 505, + "y": 225, + "name": "s7e2l", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "eIxSb", + "name": "s7e2i", + "width": 9, + "height": 9, + "iconFontName": "arrow-right", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "UjFPT", + "name": "s7e2t", + "fill": "#888888", + "content": "Default", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "line", + "id": "B9ikv", + "x": 484, + "y": 245, + "name": "s7defLine", + "width": 96, + "height": 0, + "stroke": { + "thickness": 2, + "cap": "round", + "fill": "#6D6D7D80" + } + } + ] + }, + { + "type": "frame", + "id": "qhaTR", + "name": "ChatPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "TQXXI", + "name": "Messages", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 20, + 20, + 0, + 20 + ], + "children": [ + { + "type": "frame", + "id": "oXehR", + "name": "um7", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "hd3z0", + "name": "ub7", + "fill": "#C52D3A", + "cornerRadius": [ + 12, + 12, + 4, + 12 + ], + "layout": "vertical", + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "lsfS8", + "name": "ut7", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": 260, + "content": "Rebuild this entire journey as a\n5-card onboarding flow for a\nfitness app", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Z4s7z", + "name": "ar7", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "zWESo", + "name": "av7", + "width": 28, + "height": 28, + "fill": "#F5F5F5", + "cornerRadius": 14, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "WUuAS", + "name": "avi7", + "width": 14, + "height": 14, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "qdvd3", + "name": "ac7", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "LYPaX", + "name": "at7", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "I'll rebuild the entire journey with 5 cards for a fitness onboarding. This will replace all existing cards.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "WTJbr", + "name": "PlanCard", + "clip": true, + "width": "fill_container", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Kugcr", + "name": "ph7", + "width": "fill_container", + "fill": "#FFF3E0", + "gap": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "i9DxY", + "name": "warnIcon", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "sCIi9", + "name": "warnI", + "width": 14, + "height": 14, + "iconFontName": "triangle-alert", + "iconFontFamily": "lucide", + "fill": "#F0720C" + }, + { + "type": "text", + "id": "E7VIf", + "name": "pt7", + "fill": "#F0720C", + "content": "Requires Confirmation", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "y00CC", + "name": "pb7", + "fill": "#F0720C", + "cornerRadius": 10, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "1lISs", + "name": "pbt7", + "fill": "#FFFFFF", + "content": "1 op", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "udayA", + "name": "po7", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "0VqL7", + "name": "op7a", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#F0F0F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "d4USE", + "name": "op7aS", + "fill": "#EFEFEF", + "width": 16, + "height": 16, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#F0720C" + } + }, + { + "type": "text", + "id": "FNlGA", + "name": "op7aT", + "fill": "#26262E", + "content": "Rebuild journey — replace all cards", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Q2b34", + "name": "warnMsg", + "width": "fill_container", + "fill": "#FFF8F0", + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "u2TF4", + "name": "warnMsgI", + "width": 14, + "height": 14, + "iconFontName": "info", + "iconFontFamily": "lucide", + "fill": "#F0720C" + }, + { + "type": "text", + "id": "ppNlO", + "name": "warnMsgT", + "fill": "#F0720C", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "This will replace all 3 existing cards.", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "HrPfz", + "name": "btnRow", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "bnSm9", + "name": "execBtn", + "fill": "#C52D3A", + "cornerRadius": 8, + "gap": 6, + "padding": [ + 8, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "e3OAA", + "name": "execIcon", + "width": 14, + "height": 14, + "iconFontName": "play", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "msZmV", + "name": "execTxt", + "fill": "#FFFFFF", + "content": "Execute", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "frame", + "id": "o0lC0", + "name": "cancelBtn", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "gap": 6, + "padding": [ + 8, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "zc0W5", + "name": "cancelTxt", + "fill": "#6D6D7D", + "content": "Cancel", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Jv1Ja", + "name": "mi7", + "width": "fill_container", + "gap": 4, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "GHN80", + "name": "md7", + "fill": "#3AA74A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "EwsIh", + "name": "mt7", + "fill": "#AAAAAA", + "content": "AI-powered · Use your own Claude key →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "AhXpN", + "name": "ib7", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "rNQPH", + "name": "if7", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "yafu3", + "name": "ift7", + "fill": "#AAAAAA", + "content": "Describe what you want to change...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "JVos3", + "name": "snd7", + "width": 44, + "height": 44, + "fill": "#CCCCCC", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "hkQjZ", + "name": "sni7", + "width": 20, + "height": 20, + "iconFontName": "arrow-up", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "r8agi", + "x": 1380, + "y": -32, + "name": "lbl2", + "fill": "#26262E", + "content": "2 — Empty State (AI Editor)", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "5gQtq", + "x": 2760, + "y": -32, + "name": "lbl3", + "fill": "#26262E", + "content": "3 — Thinking", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "yiF4k", + "x": 0, + "y": 888, + "name": "lbl4", + "fill": "#26262E", + "content": "4 — Streaming + Plan", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "zdoma", + "x": 1380, + "y": 888, + "name": "lbl5", + "fill": "#26262E", + "content": "5 — Executing", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "c8qIW", + "x": 2760, + "y": 888, + "name": "lbl6", + "fill": "#26262E", + "content": "6 — Completed", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "YtqML", + "x": 0, + "y": 1808, + "name": "lbl7", + "fill": "#26262E", + "content": "7 — Confirmation (Destructive)", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "djZTy", + "x": 1380, + "y": 1808, + "name": "lbl8", + "fill": "#26262E", + "content": "8 — Branching Flow", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "H6R1y", + "x": 1380, + "y": 1840, + "name": "8 — Branching Flow", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "VABx9", + "name": "Toolbar", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "Dw8n4", + "name": "tb8l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "m1Rdq", + "name": "tb8bi", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "ooxO4", + "name": "tb8bt", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "JgA4V", + "name": "tb8c", + "fill": "#26262E", + "content": "Fitness Onboarding", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "icon_font", + "id": "aOmKH", + "x": 1236, + "y": 14, + "name": "tb8r", + "enabled": false, + "width": 20, + "height": 20, + "iconFontName": "undo-2", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "ifFhZ", + "name": "bd8", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "oNpe2", + "name": "Canvas", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "0lc1D", + "x": 20, + "y": 200, + "name": "bWelcome", + "clip": true, + "width": 140, + "height": 230, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 14, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000018", + "offset": { + "x": 0, + "y": 3 + }, + "blur": 12 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "JuTWJ", + "name": "bWs", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "vm3JI", + "name": "bWc", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "padding": [ + 12, + 12, + 16, + 12 + ], + "children": [ + { + "type": "text", + "id": "SKG1S", + "name": "bWh", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "text", + "id": "CXO8M", + "name": "bWb", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "What's your\nfitness goal?", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "DhCtf", + "name": "bWbtn", + "width": "fill_container", + "fill": "#C52D3A", + "cornerRadius": 6, + "padding": [ + 8, + 0 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "i37Df", + "name": "bWbtxt", + "fill": "#FFFFFF", + "content": "Let's Go", + "fontFamily": "Montserrat", + "fontSize": 10, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "pnpez", + "x": 250, + "y": 200, + "name": "bPoll", + "clip": true, + "width": 140, + "height": 230, + "fill": "#FFFFFF", + "cornerRadius": 14, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000018", + "offset": { + "x": 0, + "y": 3 + }, + "blur": 12 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "M9h86", + "name": "bPc", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 18, + 12, + 16, + 12 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "sogXB", + "name": "bPh", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "What's your\ngoal?", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "ibObj", + "name": "bPo1", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 10 + ], + "children": [ + { + "type": "text", + "id": "pPSTk", + "name": "bPo1t", + "fill": "#26262E", + "content": "Lose Weight", + "fontFamily": "Open Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Kp4h3", + "name": "bPo2", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 10 + ], + "children": [ + { + "type": "text", + "id": "yoJHT", + "name": "bPo2t", + "fill": "#26262E", + "content": "Build Muscle", + "fontFamily": "Open Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "itVtU", + "name": "bPo3", + "width": "fill_container", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 10 + ], + "children": [ + { + "type": "text", + "id": "Y5HDN", + "name": "bPo3t", + "fill": "#26262E", + "content": "Stay Active", + "fontFamily": "Open Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zPsww", + "x": 510, + "y": 20, + "name": "bTarget1", + "clip": true, + "width": 140, + "height": 230, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#E8505B", + "position": 0 + }, + { + "color": "#F9D56E", + "position": 1 + } + ] + }, + "cornerRadius": 14, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000018", + "offset": { + "x": 0, + "y": 3 + }, + "blur": 12 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "TvZsP", + "name": "bt1s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "5Ghvr", + "name": "bt1c", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "padding": [ + 12, + 12, + 16, + 12 + ], + "children": [ + { + "type": "text", + "id": "qTbuy", + "name": "bt1h", + "fill": "#FFFFFF", + "content": "Lose Weight", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "text", + "id": "bbcNo", + "name": "bt1b", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Your personalized weight loss plan starts here.", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "TJoj4", + "x": 510, + "y": 265, + "name": "bT2", + "clip": true, + "width": 140, + "height": 230, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#667EEA", + "position": 0 + }, + { + "color": "#764BA2", + "position": 1 + } + ] + }, + "cornerRadius": 14, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000018", + "offset": { + "x": 0, + "y": 3 + }, + "blur": 12 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "ULlCn", + "name": "bt2s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "4Qxfx", + "name": "bt2c", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "padding": [ + 12, + 12, + 16, + 12 + ], + "children": [ + { + "type": "text", + "id": "z0QSL", + "name": "bt2h", + "fill": "#FFFFFF", + "content": "Build Muscle", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "text", + "id": "WUWTO", + "name": "bt2b", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Strength training program tailored for you.", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "iHYnS", + "x": 510, + "y": 510, + "name": "bT3", + "clip": true, + "width": 140, + "height": 230, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#11998E", + "position": 0 + }, + { + "color": "#38EF7D", + "position": 1 + } + ] + }, + "cornerRadius": 14, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000018", + "offset": { + "x": 0, + "y": 3 + }, + "blur": 12 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "QheHM", + "name": "bt3s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "03lLc", + "name": "bt3c", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "padding": [ + 12, + 12, + 16, + 12 + ], + "children": [ + { + "type": "text", + "id": "CgWpq", + "name": "bt3h", + "fill": "#FFFFFF", + "content": "Stay Active", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "700" + }, + { + "type": "text", + "id": "LrzWA", + "name": "bt3b", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Daily activity plan to keep you moving.", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "ellipse", + "id": "qoKIH", + "x": 156, + "y": 311, + "name": "e0src", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#6D6D7DA0" + } + }, + { + "type": "path", + "id": "akXcv", + "x": 164, + "y": 314, + "name": "e0line", + "geometry": "M0 2l86 0", + "width": 86, + "height": 4, + "stroke": { + "thickness": 2, + "fill": "#6D6D7D30" + } + }, + { + "type": "path", + "id": "rLPrK", + "x": 246, + "y": 310, + "name": "e0arrow", + "geometry": "M0 0l7 5-7 5z", + "fill": "#6D6D7D30", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "UHq75", + "x": 172, + "y": 298, + "name": "e0lbl", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 4, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "q0bDU", + "name": "e0icon", + "width": 9, + "height": 9, + "iconFontName": "mouse-pointer-click", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + }, + { + "type": "text", + "id": "8mPu9", + "name": "e0txt", + "fill": "#444451", + "content": "Let's Go", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "ellipse", + "id": "IWpav", + "x": 386, + "y": 276, + "name": "br1src", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#4c9bf8" + } + }, + { + "type": "path", + "id": "8ZZMo", + "x": 394, + "y": 135, + "name": "br1path", + "geometry": "M0 145c50 0 66-145 116-145", + "width": 116, + "height": 150, + "stroke": { + "thickness": 2, + "fill": "#4c9bf880" + } + }, + { + "type": "path", + "id": "qRLl6", + "x": 506, + "y": 131, + "name": "br1arrow", + "geometry": "M0 0l7 5-7 5z", + "fill": "#4c9bf880", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "3CUE7", + "x": 420, + "y": 185, + "name": "br1lbl", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "gW7VW", + "name": "br1i", + "width": 9, + "height": 9, + "iconFontName": "git-branch", + "iconFontFamily": "lucide", + "fill": "#4c9bf8" + }, + { + "type": "text", + "id": "zOSLU", + "name": "br1t", + "fill": "#444451", + "content": "Lose Weight", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "ellipse", + "id": "Z9jzP", + "x": 386, + "y": 315, + "name": "br2src", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#4c9bf8" + } + }, + { + "type": "path", + "id": "rrZxK", + "x": 394, + "y": 319, + "name": "br2path", + "geometry": "M0 0c50 0 66 62 116 62", + "width": 116, + "height": 62, + "stroke": { + "thickness": 2, + "fill": "#4c9bf880" + } + }, + { + "type": "path", + "id": "BczYF", + "x": 506, + "y": 377, + "name": "br2arrow", + "geometry": "M0 0l7 5-7 5z", + "fill": "#4c9bf880", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "jjNIu", + "x": 420, + "y": 345, + "name": "br2lbl", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "NHivX", + "name": "br2i", + "width": 9, + "height": 9, + "iconFontName": "git-branch", + "iconFontFamily": "lucide", + "fill": "#667EEA" + }, + { + "type": "text", + "id": "Y4r2X", + "name": "br2t", + "fill": "#444451", + "content": "Build Muscle", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "ellipse", + "id": "6Zihv", + "x": 386, + "y": 353, + "name": "br3src", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#4c9bf8" + } + }, + { + "type": "path", + "id": "FfVWD", + "x": 394, + "y": 357, + "name": "br3path", + "geometry": "M0 0c50 0 66 270 116 270", + "width": 116, + "height": 270, + "stroke": { + "thickness": 2, + "fill": "#4c9bf880" + } + }, + { + "type": "path", + "id": "1XGHv", + "x": 506, + "y": 623, + "name": "br3arrow", + "geometry": "M0 0l7 5-7 5z", + "fill": "#4c9bf880", + "width": 7, + "height": 10 + }, + { + "type": "frame", + "id": "RaxI6", + "x": 420, + "y": 520, + "name": "br3lbl", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "jaA4N", + "name": "br3i", + "width": 9, + "height": 9, + "iconFontName": "git-branch", + "iconFontFamily": "lucide", + "fill": "#11998E" + }, + { + "type": "text", + "id": "7KRTK", + "name": "br3t", + "fill": "#444451", + "content": "Stay Active", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "ellipse", + "id": "l6Jyd", + "x": 386, + "y": 395, + "name": "dfSrc", + "fill": "#FFFFFF", + "width": 8, + "height": 8, + "stroke": { + "align": "center", + "thickness": 2, + "fill": "#6D6D7D80" + } + }, + { + "type": "frame", + "id": "SrFso", + "x": 430, + "y": 383, + "name": "dfLbl", + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E5E5E5" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000A", + "offset": { + "x": 0, + "y": 1 + }, + "blur": 3 + }, + "gap": 3, + "padding": [ + 3, + 8 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "T954o", + "name": "dfIcon", + "width": 9, + "height": 9, + "iconFontName": "arrow-right", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "oHmlZ", + "name": "dfTxt", + "fill": "#888888", + "content": "Default", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + }, + { + "type": "rectangle", + "cornerRadius": 1, + "id": "cbooM", + "x": 394, + "y": 398, + "name": "s8defRect", + "fill": "#6D6D7D", + "width": 116, + "height": 2 + }, + { + "type": "path", + "id": "wUNtK", + "x": 506, + "y": 393, + "name": "s8defArrow", + "geometry": "M0 0l8 6-8 6z", + "fill": "#6D6D7D", + "width": 8, + "height": 12 + } + ] + }, + { + "type": "frame", + "id": "hH9ur", + "name": "ChatPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "doiLI", + "name": "Messages", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 20, + 20, + 0, + 20 + ], + "children": [ + { + "type": "frame", + "id": "sHNdY", + "name": "um8", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "a40Zn", + "name": "ub8", + "fill": "#C52D3A", + "cornerRadius": [ + 12, + 12, + 4, + 12 + ], + "layout": "vertical", + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "RvQb7", + "name": "ut8", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": 260, + "content": "Build a fitness onboarding flow\nwith a goal poll that branches to\n3 different paths", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Q6i30", + "name": "ar8", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "EdAhb", + "name": "av8", + "width": 28, + "height": 28, + "fill": "#F5F5F5", + "cornerRadius": 14, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "GlFYg", + "name": "avi8", + "width": 14, + "height": 14, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "artMW", + "name": "ac8", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "VPRBm", + "name": "at8", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Done! Built a 5-card fitness onboarding with a goal poll that branches into 3 personalized paths.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "gWQWA", + "name": "PlanCard", + "clip": true, + "width": "fill_container", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "yunm1", + "name": "ph8", + "width": "fill_container", + "fill": "#F5F5F5", + "gap": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "FRv1Z", + "name": "pt8", + "fill": "#26262E", + "content": "Execution Plan", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "P1trN", + "name": "pb8", + "fill": "#3AA74A", + "cornerRadius": 10, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "00XUk", + "name": "pbt8", + "fill": "#FFFFFF", + "content": "Complete", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "jPyb0", + "name": "po8", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "IjGOy", + "name": "op8a", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#F0F0F0" + }, + "gap": 8, + "padding": [ + 8, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "1QpM3", + "name": "op8aI", + "width": 14, + "height": 14, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "jnehF", + "name": "op8aT", + "fill": "#6D6D7D", + "content": "Create 5 cards with fitness content", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "bKZb6", + "name": "op8b", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#F0F0F0" + }, + "gap": 8, + "padding": [ + 8, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ZPmnp", + "name": "op8bI", + "width": 14, + "height": 14, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "M4dcG", + "name": "op8bT", + "fill": "#6D6D7D", + "content": "Add goal poll with 3 options", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "OUA0L", + "name": "op8c", + "width": "fill_container", + "gap": 8, + "padding": [ + 8, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "WS2N2", + "name": "op8cI", + "width": 14, + "height": 14, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "wNo00", + "name": "op8cT", + "fill": "#6D6D7D", + "content": "Connect each option to its path", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "IQQeN", + "name": "mi8", + "width": "fill_container", + "gap": 4, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "vg9CF", + "name": "md8", + "fill": "#3AA74A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "fMWzt", + "name": "mt8", + "fill": "#AAAAAA", + "content": "AI-powered · Use your own Claude key →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "e8SwQ", + "name": "ib8", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "A9GK6", + "name": "if8", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "z81CB", + "name": "ift8", + "fill": "#AAAAAA", + "content": "Describe what you want to change...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "kJdtM", + "name": "snd8", + "width": 44, + "height": 44, + "fill": "#C52D3A", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Pn4Bi", + "name": "sni8", + "width": 20, + "height": 20, + "iconFontName": "arrow-up", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "8yJGs", + "x": 2760, + "y": 1808, + "name": "lbl9", + "fill": "#26262E", + "content": "9 — Undo Confirmation", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "FITHm", + "x": 2760, + "y": 1840, + "name": "9 — Undo Confirmation", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "JFHnr", + "name": "Toolbar", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "o726G", + "name": "tb9l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "PNX69", + "name": "tb9bi", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "VNLSM", + "name": "tb9bt", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "TFHI0", + "name": "tb9c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "zKKzV", + "name": "tb9r", + "width": 20, + "height": 20 + } + ] + }, + { + "type": "frame", + "id": "SCgay", + "name": "bd9", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "JSKku", + "name": "Canvas", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "DQQ3M", + "x": 40, + "y": 100, + "name": "s9ca", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "jadfA", + "name": "s9cas", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "A9hAy", + "name": "s9cac", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "6kk3B", + "name": "s9cah", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "6yg0v", + "name": "s9cab", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Start your journey here and discover something new.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "AKlnD", + "name": "s9cabt", + "width": "fill_container", + "fill": "#C52D3A", + "cornerRadius": 8, + "padding": [ + 10, + 0 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "oLHTZ", + "name": "s9cabtx", + "fill": "#FFFFFF", + "content": "Get Started", + "fontFamily": "Montserrat", + "fontSize": 11, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "tC131", + "x": 310, + "y": 100, + "name": "s9cb", + "clip": true, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "outside", + "thickness": 2, + "fill": "#3AA74A" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#3AA74A25", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "kGHLk", + "name": "s9cbc", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "tyyz7", + "name": "s9cbh", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "4VLE7", + "name": "s9cbo1", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "fTlWs", + "name": "s9cbo1t", + "fill": "#26262E", + "content": "Social Media", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "2wJYr", + "name": "s9cbo2", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "KCYV0", + "name": "s9cbo2t", + "fill": "#26262E", + "content": "Friend / Family", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "text", + "id": "DXa2e", + "name": "s9eml", + "fill": "#6D6D7D", + "content": "Email", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "Vf729", + "name": "s9emli", + "width": "fill_container", + "fill": "#FAFAFA", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 12 + ], + "children": [ + { + "type": "text", + "id": "PISUc", + "name": "s9emlp", + "fill": "#AAAAAA", + "content": "you@example.com", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "S6z6B", + "x": 580, + "y": 100, + "name": "s9cc", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "yJWTY", + "name": "s9ccs", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "0YMsw", + "name": "s9ccc", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "tjRCK", + "name": "s9cch", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "AsPDL", + "name": "s9ccb", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "We appreciate you taking the time to share with us.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "TjNcy", + "name": "ChatPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "TCTYI", + "name": "Messages", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 20, + 20, + 0, + 20 + ], + "children": [ + { + "type": "frame", + "id": "RjmLU", + "name": "um9", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "BFpWs", + "name": "ub9", + "fill": "#C52D3A", + "cornerRadius": [ + 12, + 12, + 4, + 12 + ], + "layout": "vertical", + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "4Ek4h", + "name": "ut9", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": 260, + "content": "Add a text input asking for the\nuser's email to card 2", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "Md4O4", + "layoutPosition": "absolute", + "x": 264, + "y": 35, + "name": "undoOnBubble9", + "fill": "#C52D3A", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#9e263000" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#C52D3A30", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 6 + }, + "gap": 4, + "padding": [ + 4, + 6 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Y2qiY", + "name": "undoObi9", + "width": 11, + "height": 11, + "iconFontName": "undo-2", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "6JhVK", + "name": "ar9", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "AgmGO", + "name": "av9", + "width": 28, + "height": 28, + "fill": "#F5F5F5", + "cornerRadius": 14, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "B41pt", + "name": "avi9", + "width": 14, + "height": 14, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "EotwG", + "name": "ac9", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "1452m", + "name": "at9", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Done! I added an email input field to the \"How did you hear about us?\" card.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "7DH85", + "name": "PlanCard", + "clip": true, + "width": "fill_container", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "KoF9V", + "name": "ph9", + "width": "fill_container", + "fill": "#F5F5F5", + "gap": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XriaB", + "name": "pt9", + "fill": "#26262E", + "content": "Execution Plan", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "ryU3r", + "name": "pb9", + "fill": "#3AA74A", + "cornerRadius": 10, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "53zgD", + "name": "pbt9", + "fill": "#FFFFFF", + "content": "Complete", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "XiAYo", + "name": "po9", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Ilx1S", + "name": "op9a", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#F0F0F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "lm3Di", + "name": "op9aI", + "width": 16, + "height": 16, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "ryjcp", + "name": "op9aT", + "fill": "#6D6D7D", + "content": "Add email input to \"How did you hear?\"", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "2s6a1", + "name": "op9b", + "width": "fill_container", + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rLnwY", + "name": "op9bI", + "width": 16, + "height": 16, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "ezYwF", + "name": "op9bT", + "fill": "#6D6D7D", + "content": "Connect \"How did you hear?\" → \"Thank You\"", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Xaub0", + "name": "undoConf9", + "width": "fill_container", + "fill": "#FFF8F0", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F0720C30" + }, + "layout": "vertical", + "gap": 8, + "padding": [ + 12, + 14 + ], + "children": [ + { + "type": "frame", + "id": "c1k4F", + "name": "undoQ9", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "XnSVV", + "name": "undoQi", + "width": 16, + "height": 16, + "iconFontName": "triangle-alert", + "iconFontFamily": "lucide", + "fill": "#F0720C" + }, + { + "type": "text", + "id": "iYCPW", + "name": "undoQt", + "fill": "#26262E", + "content": "Revert all changes from this turn?", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "Un17Y", + "name": "undoDesc", + "fill": "#6D6D7D", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "This will remove the email input from \"How did you hear?\" and undo the navigation change.", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "FKyJh", + "name": "undoBtns9", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "kTSZg", + "name": "undoYes9", + "fill": "#B62D1C", + "cornerRadius": 8, + "gap": 4, + "padding": [ + 7, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "xjacj", + "name": "undoYi9", + "width": 12, + "height": 12, + "iconFontName": "undo-2", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "6hW4T", + "name": "undoYt9", + "fill": "#FFFFFF", + "content": "Yes, revert", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "0jUDg", + "name": "undoNo9", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 7, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "drhLO", + "name": "undoNt9", + "fill": "#6D6D7D", + "content": "Cancel", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "umefR", + "name": "mi9", + "width": "fill_container", + "gap": 4, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "kgrnH", + "name": "md9", + "fill": "#3AA74A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "LXxUB", + "name": "mt9", + "fill": "#AAAAAA", + "content": "AI-powered · Use your own Claude key →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "IMF6w", + "name": "ib9", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "DnbPE", + "name": "if9", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "SOdoo", + "name": "ift9", + "fill": "#AAAAAA", + "content": "Describe what you want to change...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "WvF2l", + "name": "snd9", + "width": 44, + "height": 44, + "fill": "#C52D3A", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "1l2Au", + "name": "sni9", + "width": 20, + "height": 20, + "iconFontName": "arrow-up", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "Fye9b", + "x": 0, + "y": 2708, + "name": "lbl10", + "fill": "#26262E", + "content": "10 — API Key Setup Modal", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "xeYHR", + "x": 0, + "y": 2740, + "name": "10 — API Key Setup", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "1dARt", + "name": "Toolbar", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "fN7Xm", + "name": "tb10l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "qyF81", + "name": "tb10bi", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "FbVAU", + "name": "tb10bt", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "TDkkJ", + "name": "tb10c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "rsWBs", + "name": "tb10r", + "width": 20, + "height": 20 + } + ] + }, + { + "type": "frame", + "id": "z2zXP", + "name": "bd10", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "uIE2X", + "name": "Canvas", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "wWAxv", + "x": 40, + "y": 100, + "name": "s10c1", + "opacity": 0.4, + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Yxqj4", + "name": "s10c1s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "E9gV9", + "name": "s10c1c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "U1Pxj", + "name": "s10c1h", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "rZ15t", + "x": 310, + "y": 100, + "name": "s10c2", + "opacity": 0.4, + "clip": true, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "cxcO9", + "name": "s10c2c", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "A3t8D", + "name": "s10c2h", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Yvxls", + "x": 580, + "y": 100, + "name": "s10c3", + "opacity": 0.4, + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "oDJmq", + "name": "s10c3s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "KeVqs", + "name": "s10c3c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "3IuUS", + "name": "s10c3h", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "6BngP", + "name": "ChatPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "ULC8N", + "name": "chatBg", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "0qheq", + "name": "APIKeyModal", + "width": 420, + "fill": "#FFFFFF", + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 8 + }, + "blur": 32 + }, + "layout": "vertical", + "gap": 20, + "padding": [ + 28, + 28, + 24, + 28 + ], + "children": [ + { + "type": "frame", + "id": "EgIkA", + "name": "modalHeader", + "width": "fill_container", + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "NzCHL", + "name": "modalTitle", + "fill": "#26262E", + "content": "Use your own Claude key", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "icon_font", + "id": "etFLg", + "name": "modalClose", + "width": 20, + "height": 20, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "#AAAAAA" + } + ] + }, + { + "type": "text", + "id": "Fp9Fd", + "name": "modalDesc", + "fill": "#6D6D7D", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Connect your Anthropic API key to use Claude Sonnet 4 instead of the default Gemini Flash. Your key is encrypted and never exposed to the browser.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "dDUEH", + "name": "tierCompare", + "width": "fill_container", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "3HdQC", + "name": "tierFree", + "width": "fill_container", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "gap": 8, + "padding": 14, + "children": [ + { + "type": "frame", + "id": "WaE23", + "name": "tierFreeLabel", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "5LX2J", + "name": "tierFreeDot", + "fill": "#3AA74A", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "7ymuJ", + "name": "tierFreeName", + "fill": "#26262E", + "content": "Free", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "NJ8w9", + "name": "tierFreeModel", + "fill": "#6D6D7D", + "content": "Gemini 2.5 Flash", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "phxT9", + "name": "tierFreeDesc", + "fill": "#AAAAAA", + "content": "Good for simple edits", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "GjTVF", + "name": "tierPrem", + "width": "fill_container", + "fill": "#FEF5F5", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#C52D3A" + }, + "layout": "vertical", + "gap": 8, + "padding": 14, + "children": [ + { + "type": "frame", + "id": "Ek7IJ", + "name": "tierPremLabel", + "gap": 6, + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "M84ej", + "name": "tierPremDot", + "fill": "#C52D3A", + "width": 8, + "height": 8 + }, + { + "type": "text", + "id": "8b7sB", + "name": "tierPremName", + "fill": "#C52D3A", + "content": "Premium", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "700" + } + ] + }, + { + "type": "text", + "id": "xlKOZ", + "name": "tierPremModel", + "fill": "#26262E", + "content": "Claude Sonnet 4", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "600" + }, + { + "type": "text", + "id": "S3Q6a", + "name": "tierPremDesc", + "fill": "#6D6D7D", + "content": "Best for complex flows", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "UXNlN", + "name": "inputSection", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "apmxt", + "name": "inputLabel", + "fill": "#26262E", + "content": "Anthropic API Key", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "Q2DBk", + "name": "inputField", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "gap": 8, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "8F9Sb", + "name": "inputIcon", + "width": 16, + "height": 16, + "iconFontName": "key-round", + "iconFontFamily": "lucide", + "fill": "#AAAAAA" + }, + { + "type": "text", + "id": "HqUbB", + "name": "inputPlc", + "fill": "#AAAAAA", + "content": "sk-ant-api03-...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "CDynI", + "name": "inputHint", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "JReYp", + "name": "inputHintIcon", + "width": 10, + "height": 10, + "iconFontName": "lock", + "iconFontFamily": "lucide", + "fill": "#AAAAAA" + }, + { + "type": "text", + "id": "r1uxm", + "name": "inputHintTxt", + "fill": "#AAAAAA", + "content": "Encrypted with AES-256. Never stored in the browser.", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "quRDa", + "name": "btnRow", + "width": "fill_container", + "gap": 10, + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "SMIk4", + "name": "cancelBtn", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "HjtMG", + "name": "cancelTxt", + "fill": "#6D6D7D", + "content": "Cancel", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "gfxAY", + "name": "saveBtn", + "fill": "#C52D3A", + "cornerRadius": 10, + "gap": 6, + "padding": [ + 10, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "8e6UX", + "name": "saveIcon", + "width": 16, + "height": 16, + "iconFontName": "check", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "4xj4n", + "name": "saveTxt", + "fill": "#FFFFFF", + "content": "Connect Key", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "FlhPU", + "x": 1380, + "y": 2708, + "name": "lbl11", + "fill": "#26262E", + "content": "11 — Claude Connected", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "oY14g", + "x": 1380, + "y": 2740, + "name": "11 — Claude Connected", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "ANK7D", + "name": "Toolbar", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "o00BO", + "name": "tb11l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "RR6dZ", + "name": "tb11bi", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "dnBBj", + "name": "tb11bt", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "oX7zF", + "name": "tb11c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "LaPtq", + "name": "tb11r", + "width": 20, + "height": 20 + } + ] + }, + { + "type": "frame", + "id": "xh9uo", + "name": "bd11", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "eFxvv", + "name": "Canvas", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "LhNSJ", + "x": 40, + "y": 100, + "name": "s11c1", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "qjJ7D", + "name": "s11c1s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "VqU7L", + "name": "s11c1c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "93Ove", + "name": "s11c1h", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "WaTt9", + "name": "s11c1b", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Start your journey here and discover something new.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "GARwV", + "name": "s11c1bt", + "width": "fill_container", + "fill": "#C52D3A", + "cornerRadius": 8, + "padding": [ + 10, + 0 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "ENSi3", + "name": "s11c1btx", + "fill": "#FFFFFF", + "content": "Get Started", + "fontFamily": "Montserrat", + "fontSize": 11, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "6rSEC", + "x": 310, + "y": 100, + "name": "s11c2", + "clip": true, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "UTFrw", + "name": "s11c2c", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "fZgNJ", + "name": "s11c2h", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "IF6ij", + "name": "s11c2o1", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "IHT8K", + "name": "s11c2o1t", + "fill": "#26262E", + "content": "Social Media", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "xuq2D", + "x": 580, + "y": 100, + "name": "s11c3", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "GfSyx", + "name": "s11c3s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "zTo28", + "name": "s11c3c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "ebtNe", + "name": "s11c3h", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "FPddp", + "name": "s11c3b", + "fill": "#FFFFFFCC", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "We appreciate you taking the time to share with us.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "Tbkm8", + "name": "ChatPanel", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "mbav5", + "name": "chatArea", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 24, + 20, + 0, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "VeLFn", + "name": "emptyIcon", + "width": 40, + "height": 40, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#DEDFE0" + }, + { + "type": "text", + "id": "1JSt4", + "name": "emptyTitle", + "fill": "#26262E", + "content": "AI Journey Editor", + "textAlign": "center", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "wHa7z", + "name": "emptyDesc", + "fill": "#6D6D7D", + "textGrowth": "fixed-width", + "width": 340, + "content": "Now powered by Claude Sonnet 4.\nDescribe what you want to build or change.", + "lineHeight": 1.5, + "textAlign": "center", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "yP4Au", + "name": "chip1", + "gap": 8, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "fjT0B", + "name": "c1", + "fill": "#FAFAFA", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 14 + ], + "children": [ + { + "type": "text", + "id": "nkt8w", + "name": "c1t", + "fill": "#444451", + "content": "Build a 5-card onboarding flow", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "VmWYA", + "name": "c2", + "fill": "#FAFAFA", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 14 + ], + "children": [ + { + "type": "text", + "id": "EZvfU", + "name": "c2t", + "fill": "#444451", + "content": "Add a poll to card 2", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "zZ4fW", + "name": "chip2row", + "gap": 8, + "justifyContent": "center", + "children": [ + { + "type": "frame", + "id": "xP3ZK", + "name": "c3chip", + "fill": "#FAFAFA", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 14 + ], + "children": [ + { + "type": "text", + "id": "rjSpH", + "name": "c3chipt", + "fill": "#444451", + "content": "Translate all cards to Spanish", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "Jn4uo", + "name": "c4chip", + "fill": "#FAFAFA", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 14 + ], + "children": [ + { + "type": "text", + "id": "sWkGZ", + "name": "c4chipt", + "fill": "#444451", + "content": "Add images to each card", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "8Y9QF", + "name": "successToast", + "fill": "#E8F5E9", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#3AA74A30" + }, + "gap": 8, + "padding": [ + 10, + 16 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "8IhCq", + "name": "toastIcon", + "width": 16, + "height": 16, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "kFVm9", + "name": "toastTxt", + "fill": "#2E7D32", + "content": "Claude Sonnet 4 connected successfully", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + }, + { + "type": "frame", + "id": "3pQHE", + "name": "modelInd", + "width": "fill_container", + "gap": 6, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "JPJEv", + "name": "modelIcon", + "width": 12, + "height": 12, + "iconFontName": "zap", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + }, + { + "type": "text", + "id": "cepC8", + "name": "modelTxt", + "fill": "#C52D3A", + "content": "Powered by Claude Sonnet 4 · Settings →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "yJujh", + "name": "inputBar", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "SxMW3", + "name": "inputFld", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "6VukP", + "name": "inputPlc", + "fill": "#AAAAAA", + "content": "Describe what you want to change...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "E4PHU", + "name": "sendBtn", + "width": 44, + "height": 44, + "fill": "#C52D3A", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "RBLDg", + "name": "sendIcon", + "width": 20, + "height": 20, + "iconFontName": "arrow-up", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "88SW1", + "x": 2760, + "y": 2708, + "name": "lbl12", + "fill": "#26262E", + "content": "12 — Error: Operation Failed", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "48FuL", + "x": 2760, + "y": 2740, + "name": "12 — Error", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "d1pSM", + "name": "tb12", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "pNUZg", + "name": "tb12l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "eIMXr", + "name": "tb12i", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "HUl8T", + "name": "tb12t", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "nkG3q", + "name": "tb12c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "atJyZ", + "name": "tb12r", + "width": 20, + "height": 20 + } + ] + }, + { + "type": "frame", + "id": "wkVqO", + "name": "bd12", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "F8Cbl", + "name": "cv12", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "dKEAN", + "x": 40, + "y": 100, + "name": "e12c1", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Iqy6J", + "name": "e12c1s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "tk9lw", + "name": "e12c1c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "ZuUu3", + "name": "e12c1h", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "HYFD6", + "x": 310, + "y": 100, + "name": "e12c2", + "clip": true, + "width": 170, + "height": 290, + "cornerRadius": 16, + "stroke": { + "align": "outside", + "thickness": 2, + "fill": "#B62D1C" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#B62D1C30", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 20 + }, + "layout": "none", + "children": [ + { + "type": "frame", + "id": "Uf25d", + "x": 0, + "y": 0, + "name": "e12c2bg", + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "layout": "vertical", + "gap": 10, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "62fSA", + "name": "e12c2h", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "urDEG", + "name": "e12c2o", + "width": "fill_container", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1.5, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "dg5De", + "name": "e12c2ot", + "fill": "#26262E", + "content": "Social Media", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "KZrVN", + "x": 138, + "y": 8, + "name": "e12errBadge", + "width": 24, + "height": 24, + "fill": "#B62D1C", + "cornerRadius": 12, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#B62D1C50", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 6 + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "7jAyv", + "name": "e12errIcon", + "width": 14, + "height": 14, + "iconFontName": "x", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + }, + { + "type": "frame", + "id": "rXG3O", + "x": 580, + "y": 100, + "name": "e12c3", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "hNYWA", + "name": "e12c3s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "iXIPa", + "name": "e12c3c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "a3jXx", + "name": "e12c3h", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "wsOAO", + "name": "cp12", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "xwHJ5", + "name": "ms12", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 20, + 20, + 0, + 20 + ], + "children": [ + { + "type": "frame", + "id": "cuIRV", + "name": "um12", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "v9U3L", + "name": "ub12", + "fill": "#C52D3A", + "cornerRadius": [ + 12, + 12, + 4, + 12 + ], + "layout": "vertical", + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "BPkFu", + "name": "ut12", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": 260, + "content": "Add a text input asking for the\nuser's email to card 2", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "gl516", + "name": "ar12", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "VZPHT", + "name": "av12", + "width": 28, + "height": 28, + "fill": "#F5F5F5", + "cornerRadius": 14, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "kLaMi", + "name": "avi12", + "width": 14, + "height": 14, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "V7Xrj", + "name": "ac12", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "mfLXC", + "name": "at12", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "I'll add an email input to the second card.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "xXPwF", + "name": "pc12", + "clip": true, + "width": "fill_container", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "omg3Q", + "name": "ph12", + "width": "fill_container", + "fill": "#FEF0F0", + "gap": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "nckst", + "name": "pt12", + "fill": "#26262E", + "content": "Execution Plan", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "HVwvm", + "name": "pb12", + "fill": "#B62D1C", + "cornerRadius": 10, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "gwo6s", + "name": "pbt12", + "fill": "#FFFFFF", + "content": "Failed", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "zHDXU", + "name": "po12", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "aiP7X", + "name": "op12a", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#F0F0F0" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "yHTyk", + "name": "op12aI", + "width": 16, + "height": 16, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "PAhc9", + "name": "op12aT", + "fill": "#6D6D7D", + "content": "Add email input to \"How did you hear?\"", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "fYjDK", + "name": "op12b", + "width": "fill_container", + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "GghrS", + "name": "op12bI", + "width": 16, + "height": 16, + "iconFontName": "circle-x", + "iconFontFamily": "lucide", + "fill": "#B62D1C" + }, + { + "type": "text", + "id": "mz6gq", + "name": "op12bT", + "fill": "#B62D1C", + "content": "Connect \"How did you hear?\" → \"Thank You\"", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "qKLga", + "name": "errMsg", + "width": "fill_container", + "fill": "#FEF0F0", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#B62D1C20" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "icon_font", + "id": "gGHcd", + "name": "errMsgI", + "width": 16, + "height": 16, + "iconFontName": "triangle-alert", + "iconFontFamily": "lucide", + "fill": "#B62D1C" + }, + { + "type": "frame", + "id": "YtET9", + "name": "errMsgB", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "QrL6S", + "name": "errMsgT", + "fill": "#B62D1C", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Operation failed: Card \"card-thankyou\" not found. It may have been deleted.", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "XeZJ7", + "name": "errBtns", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "sMOFr", + "name": "retryBtn", + "fill": "#B62D1C", + "cornerRadius": 6, + "gap": 4, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "uQ5E8", + "name": "retryI", + "width": 12, + "height": 12, + "iconFontName": "refresh-cw", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "Lk6bp", + "name": "retryT", + "fill": "#FFFFFF", + "content": "Retry", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "FQHCx", + "name": "undoErr", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "gap": 4, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "x0BEb", + "name": "undoErrI", + "width": 12, + "height": 12, + "iconFontName": "undo-2", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "xPI4z", + "name": "undoErrT", + "fill": "#6D6D7D", + "content": "Undo all", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "fKkpa", + "name": "mi12", + "width": "fill_container", + "gap": 4, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "aXoh2", + "name": "md12", + "fill": "#3AA74A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "SpiWG", + "name": "mt12", + "fill": "#AAAAAA", + "content": "AI-powered · Use your own Claude key →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "LIDrI", + "name": "ib12", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "iKmiW", + "name": "if12", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "iq5Hp", + "name": "ift12", + "fill": "#AAAAAA", + "content": "Describe what you want to change...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "fJzgs", + "name": "snd12", + "width": 44, + "height": 44, + "fill": "#CCCCCC", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "4fjNs", + "name": "sni12", + "width": 20, + "height": 20, + "iconFontName": "arrow-up", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "QbBfl", + "x": 0, + "y": 3628, + "name": "lbl13", + "fill": "#26262E", + "content": "13 — Empty Journey (Create with AI)", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "d46Q0", + "x": 0, + "y": 3660, + "name": "13 — Empty Journey", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Qe1Fs", + "name": "tb13", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "UpYJK", + "name": "tb13l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "3PTPl", + "name": "tb13i", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "GRCFr", + "name": "tb13t", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "SAFwZ", + "name": "tb13c", + "fill": "#AAAAAA", + "content": "Untitled Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "zH5Zs", + "name": "tb13r", + "width": 20, + "height": 20 + } + ] + }, + { + "type": "frame", + "id": "de5M2", + "name": "bd13", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "nEd2u", + "name": "cv13", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "icon_font", + "id": "xTt9G", + "x": 360, + "y": 321, + "name": "emptyIcon", + "enabled": false, + "width": 48, + "height": 48, + "iconFontName": "layout-grid", + "iconFontFamily": "lucide", + "fill": "#DEDFE0" + }, + { + "type": "text", + "id": "GXywh", + "x": 290, + "y": 340, + "name": "emptyTxt", + "fill": "#AAAAAA", + "content": "No cards yet — describe what you want to build", + "textAlign": "center", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "600" + }, + { + "type": "text", + "id": "m6vnC", + "x": 273, + "y": 383, + "name": "emptyDesc", + "enabled": false, + "fill": "#CCCCCC", + "content": "Use the AI chat to build your journey", + "textAlign": "center", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "PYMca", + "layoutPosition": "absolute", + "x": 40, + "y": 280, + "name": "SocialPreview", + "width": 160, + "height": 100, + "fill": "#FFFFFF", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000010", + "offset": { + "x": 0, + "y": 2 + }, + "blur": 8 + }, + "layout": "vertical", + "gap": 6, + "padding": 12, + "children": [ + { + "type": "frame", + "id": "WrCIp", + "name": "spLabel", + "gap": 4, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "A5NiJ", + "name": "spIcon", + "width": 12, + "height": 12, + "iconFontName": "share-2", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "QWHLn", + "name": "spTitle", + "fill": "#6D6D7D", + "content": "Social Preview", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "R1gip", + "name": "spThumb", + "width": "fill_container", + "height": "fill_container", + "fill": "#EFEFEF", + "cornerRadius": 4, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "KmhqH", + "name": "spThumbIcon", + "width": 16, + "height": 16, + "iconFontName": "image", + "iconFontFamily": "lucide", + "fill": "#CCCCCC" + } + ] + } + ] + }, + { + "type": "frame", + "id": "5QdYN", + "x": 196, + "y": 325, + "name": "startLabel", + "fill": "#FFFFFF", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 3, + 8 + ], + "children": [ + { + "type": "text", + "id": "iXoeF", + "name": "startTxt", + "fill": "#6D6D7D", + "content": "Start", + "fontFamily": "Open Sans", + "fontSize": 8, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "8Fmi6", + "name": "cp13", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "LitYF", + "name": "chat13", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 16, + "padding": [ + 24, + 20, + 0, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "7z965", + "name": "ch13icon", + "width": 40, + "height": 40, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#DEDFE0" + }, + { + "type": "text", + "id": "5TacP", + "name": "ch13title", + "fill": "#26262E", + "content": "Build your journey", + "textAlign": "center", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + }, + { + "type": "text", + "id": "llBWU", + "name": "ch13desc", + "fill": "#6D6D7D", + "textGrowth": "fixed-width", + "width": 340, + "content": "Describe what you want to create.\nThe AI will build it from scratch.", + "lineHeight": 1.5, + "textAlign": "center", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "ihNBE", + "name": "ch13chips", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "3u7Gg", + "name": "ch13c1", + "fill": "#FAFAFA", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 14 + ], + "children": [ + { + "type": "text", + "id": "L60lT", + "name": "ch13c1t", + "fill": "#444451", + "content": "5-card onboarding flow", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "wDROK", + "name": "ch13c2", + "fill": "#FAFAFA", + "cornerRadius": 20, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 14 + ], + "children": [ + { + "type": "text", + "id": "aLQiz", + "name": "ch13c2t", + "fill": "#444451", + "content": "Survey with branching", + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "500" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "6IkiR", + "name": "mi13", + "width": "fill_container", + "gap": 4, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "sikbL", + "name": "md13", + "fill": "#3AA74A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "o2ZCM", + "name": "mt13", + "fill": "#AAAAAA", + "content": "AI-powered · Use your own Claude key →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "Y1kmS", + "name": "ib13", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "068FQ", + "name": "if13", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qKCuI", + "name": "ift13", + "fill": "#AAAAAA", + "content": "Describe what you want to build...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "OiGeK", + "name": "snd13", + "width": 44, + "height": 44, + "fill": "#CCCCCC", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "1eI5z", + "name": "sni13", + "width": 20, + "height": 20, + "iconFontName": "arrow-up", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "o00Lr", + "x": 1380, + "y": 3628, + "name": "lbl14", + "fill": "#26262E", + "content": "14 — Stop Mid-Execution", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "DX3SG", + "x": 1380, + "y": 3660, + "name": "14 — Stop Mid-Execution", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "N34DM", + "name": "tb14", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "TwUBr", + "name": "tb14l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "uWNgc", + "name": "tb14i", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "Ycd3l", + "name": "tb14t", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "6l3u5", + "name": "tb14c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "glphj", + "name": "tb14r", + "width": 20, + "height": 20 + } + ] + }, + { + "type": "frame", + "id": "LIc8Z", + "name": "bd14", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "7lohT", + "name": "cv14", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "bEvp4", + "x": 40, + "y": 100, + "name": "s14c1", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "JL2Jb", + "name": "s14c1s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "CLFKi", + "name": "s14c1c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "ZVUjZ", + "name": "s14c1h", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "iIzIV", + "x": 310, + "y": 100, + "name": "s14c2", + "clip": true, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "outside", + "thickness": 2, + "fill": "#3AA74A" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#3AA74A25", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "9hu6k", + "name": "s14c2c", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "cGgjA", + "name": "s14c2h", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "jvwLF", + "name": "s14c2eml", + "fill": "#6D6D7D", + "content": "Email", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "7UyRI", + "name": "s14c2inp", + "width": "fill_container", + "fill": "#FAFAFA", + "cornerRadius": 8, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 8, + 12 + ], + "children": [ + { + "type": "text", + "id": "KsWXn", + "name": "s14c2p", + "fill": "#AAAAAA", + "content": "you@example.com", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "7TTUj", + "x": 580, + "y": 100, + "name": "s14c3", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "LN1DN", + "name": "s14c3s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "jsmt8", + "name": "s14c3c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "vWxth", + "name": "s14c3h", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "pTfbO", + "name": "cp14", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "yjjyR", + "name": "ms14", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 20, + 20, + 0, + 20 + ], + "children": [ + { + "type": "frame", + "id": "qZV68", + "name": "um14", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "wrm3z", + "name": "ub14", + "fill": "#C52D3A", + "cornerRadius": [ + 12, + 12, + 4, + 12 + ], + "layout": "vertical", + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "dyma9", + "name": "ut14", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": 260, + "content": "Add email input, a poll, and\nupdate the navigation on all cards", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "8ZOmY", + "name": "ar14", + "width": "fill_container", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "eRaNI", + "name": "av14", + "width": 28, + "height": 28, + "fill": "#F5F5F5", + "cornerRadius": 14, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Hf7hE", + "name": "avi14", + "width": 14, + "height": 14, + "iconFontName": "sparkles", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + } + ] + }, + { + "type": "frame", + "id": "q6kMO", + "name": "ac14", + "width": "fill_container", + "layout": "vertical", + "gap": 10, + "children": [ + { + "type": "text", + "id": "GbxxV", + "name": "at14", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "I'll make those changes across the journey.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "FIJMP", + "name": "pc14", + "clip": true, + "width": "fill_container", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "DBmSj", + "name": "ph14", + "width": "fill_container", + "fill": "#FFF8F0", + "gap": 8, + "padding": [ + 10, + 14 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "mMaF0", + "name": "pt14", + "fill": "#26262E", + "content": "Execution Plan", + "fontFamily": "Montserrat", + "fontSize": 12, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "I2iO1", + "name": "pb14", + "fill": "#F0720C", + "cornerRadius": 10, + "padding": [ + 2, + 8 + ], + "children": [ + { + "type": "text", + "id": "KToZu", + "name": "pbt14", + "fill": "#FFFFFF", + "content": "Stopped", + "fontFamily": "Open Sans", + "fontSize": 10, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "bWsyA", + "name": "po14", + "width": "fill_container", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "Ox5l7", + "name": "op14a", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#F0F0F0" + }, + "gap": 8, + "padding": [ + 8, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "Ko3vQ", + "name": "op14aI", + "width": 14, + "height": 14, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "xJ4NW", + "name": "op14aT", + "fill": "#6D6D7D", + "content": "Add email input to \"How did you hear?\"", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "MfgNA", + "name": "op14b", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#F0F0F0" + }, + "gap": 8, + "padding": [ + 8, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "boULF", + "name": "op14bI", + "width": 14, + "height": 14, + "iconFontName": "circle-check-big", + "iconFontFamily": "lucide", + "fill": "#3AA74A" + }, + { + "type": "text", + "id": "rjdmv", + "name": "op14bT", + "fill": "#6D6D7D", + "content": "Add poll options to \"How did you hear?\"", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "2vDIE", + "name": "op14c", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#F0F0F0" + }, + "gap": 8, + "padding": [ + 8, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "QsPU4", + "name": "op14cI", + "fill": "#FAFAFA", + "width": 14, + "height": 14, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#DEDFE0" + } + }, + { + "type": "text", + "id": "P6xdn", + "name": "op14cT", + "fill": "#AAAAAA", + "content": "Update navigation on Welcome card", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "lofoU", + "name": "op14cS", + "fill": "#AAAAAA", + "content": "Cancelled", + "fontFamily": "Open Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "5HUiV", + "name": "op14d", + "width": "fill_container", + "gap": 8, + "padding": [ + 8, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "a0iGi", + "name": "op14dI", + "fill": "#FAFAFA", + "width": 14, + "height": 14, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#DEDFE0" + } + }, + { + "type": "text", + "id": "T0IcO", + "name": "op14dT", + "fill": "#AAAAAA", + "content": "Update navigation on Thank You card", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + }, + { + "type": "text", + "id": "vXb3G", + "name": "op14dS", + "fill": "#AAAAAA", + "content": "Cancelled", + "fontFamily": "Open Sans", + "fontSize": 9, + "fontWeight": "normal" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "q77OO", + "name": "stopMsg", + "width": "fill_container", + "fill": "#FFF8F0", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F0720C20" + }, + "gap": 8, + "padding": [ + 10, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "eaQ0d", + "name": "stopI", + "width": 14, + "height": 14, + "iconFontName": "square", + "iconFontFamily": "lucide", + "fill": "#F0720C" + }, + { + "type": "text", + "id": "T29jE", + "name": "stopT", + "fill": "#F0720C", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Stopped. 2 of 4 operations completed. You can undo the completed changes.", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "7EQXi", + "name": "mi14", + "width": "fill_container", + "gap": 4, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "10kbF", + "name": "md14", + "fill": "#3AA74A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "N6BYI", + "name": "mt14", + "fill": "#AAAAAA", + "content": "AI-powered · Use your own Claude key →", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "hYmYR", + "name": "ib14", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "whEc0", + "name": "if14", + "width": "fill_container", + "height": 44, + "fill": "#FAFAFA", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "qqLV2", + "name": "ift14", + "fill": "#AAAAAA", + "content": "Describe what you want to change...", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "BasAY", + "name": "snd14", + "width": 44, + "height": 44, + "fill": "#CCCCCC", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "O0FXR", + "name": "sni14", + "width": 20, + "height": 20, + "iconFontName": "arrow-up", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "6VXOH", + "x": 2760, + "y": 3628, + "name": "lbl15", + "fill": "#26262E", + "content": "15 — Claude API Rate Limit", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "Ru504", + "x": 2760, + "y": 3660, + "name": "15 — Rate Limit", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical" + }, + { + "type": "frame", + "id": "G1GD3", + "x": 2760, + "y": 3660, + "name": "tb15", + "width": "fill_container(1280)", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "X4Tlu", + "name": "tb15l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "O3vwd", + "name": "tb15i", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "080dt", + "name": "tb15t", + "fill": "#6D6D7D", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "JVpxE", + "name": "tb15c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "d5uCY", + "name": "tb15r", + "width": 20, + "height": 20 + } + ] + }, + { + "type": "frame", + "id": "syrwh", + "x": 2760, + "y": 3708, + "name": "bd15", + "width": "fill_container(1280)", + "height": "fill_container(752)", + "children": [ + { + "type": "frame", + "id": "Qpn9q", + "name": "cv15", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F5", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "aVmVo", + "x": 40, + "y": 100, + "name": "cv15c1", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "3TUwX", + "name": "cv15c1s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "7R5BB", + "name": "cv15c1c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "4mVwC", + "name": "cv15c1h", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "bXURS", + "x": 310, + "y": 100, + "name": "cv15c2", + "clip": true, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "uJjXu", + "name": "cv15c2c", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "Ax5vm", + "name": "cv15c2h", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "0GVWg", + "x": 580, + "y": 100, + "name": "cv15c3", + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "I0qKX", + "name": "cv15c3s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "OIm1x", + "name": "cv15c3c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "wIlb2", + "name": "cv15c3h", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "MX7MK", + "name": "cp15", + "width": "fill_container", + "height": "fill_container", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "F8feu", + "name": "ms15", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 12, + "padding": [ + 20, + 20, + 0, + 20 + ], + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "vk7sD", + "name": "um15", + "width": "fill_container", + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "g4gaL", + "name": "ub15", + "fill": "#C52D3A", + "cornerRadius": [ + 12, + 12, + 4, + 12 + ], + "layout": "vertical", + "padding": [ + 10, + 14 + ], + "children": [ + { + "type": "text", + "id": "06TKW", + "name": "ut15", + "fill": "#FFFFFF", + "textGrowth": "fixed-width", + "width": 260, + "content": "Now add a video to the welcome card", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "6BUEf", + "name": "rateMsg", + "width": "fill_container", + "fill": "#FFF8F0", + "cornerRadius": 12, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#F0720C20" + }, + "gap": 10, + "padding": [ + 14, + 16 + ], + "children": [ + { + "type": "icon_font", + "id": "swnX1", + "name": "rateIcon", + "width": 18, + "height": 18, + "iconFontName": "timer", + "iconFontFamily": "lucide", + "fill": "#F0720C" + }, + { + "type": "frame", + "id": "gKdp7", + "name": "rateBody", + "width": "fill_container", + "layout": "vertical", + "gap": 6, + "children": [ + { + "type": "text", + "id": "NpJPM", + "name": "rateTitle", + "fill": "#26262E", + "content": "Claude API rate limit reached", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "700" + }, + { + "type": "text", + "id": "kAd3E", + "name": "rateDesc", + "fill": "#6D6D7D", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Your Anthropic API key has hit its rate limit. You can try again in about 2 minutes, switch to the free tier (Gemini Flash), or continue with manual editing.", + "lineHeight": 1.4, + "fontFamily": "Open Sans", + "fontSize": 12, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "7VEyE", + "name": "rateBtns", + "gap": 8, + "children": [ + { + "type": "frame", + "id": "YV04K", + "name": "rateManual", + "fill": "#444451", + "cornerRadius": 6, + "gap": 4, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "bdWx6", + "name": "rateManualI", + "width": 12, + "height": 12, + "iconFontName": "pencil", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "fgpBe", + "name": "rateManualT", + "fill": "#FFFFFF", + "content": "Edit Manually", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "xkxaG", + "name": "rateUpgrade", + "cornerRadius": 6, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "gap": 4, + "padding": [ + 6, + 12 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "tdzf9", + "name": "rateUpgradeI", + "width": 12, + "height": 12, + "iconFontName": "arrow-right-left", + "iconFontFamily": "lucide", + "fill": "#6D6D7D" + }, + { + "type": "text", + "id": "Vdp1C", + "name": "rateUpgradeT", + "fill": "#6D6D7D", + "content": "Switch to free tier", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "9dtsz", + "name": "mi15", + "width": "fill_container", + "gap": 6, + "padding": [ + 8, + 20 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "ellipse", + "id": "4gQBJ", + "name": "md15", + "fill": "#C52D3A", + "width": 6, + "height": 6 + }, + { + "type": "text", + "id": "jzwU6", + "name": "mt15", + "fill": "#C52D3A", + "content": "Claude rate limited · Resets in ~2 min", + "fontFamily": "Open Sans", + "fontSize": 11, + "fontWeight": "500" + } + ] + }, + { + "type": "frame", + "id": "4tqPf", + "name": "ib15", + "width": "fill_container", + "gap": 8, + "padding": [ + 12, + 20, + 16, + 20 + ], + "alignItems": "end", + "children": [ + { + "type": "frame", + "id": "dXnId", + "name": "if15", + "width": "fill_container", + "height": 44, + "fill": "#F5F5F5", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 12, + 14 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "KgbI1", + "name": "ift15", + "fill": "#AAAAAA", + "content": "Claude rate limited — try again in ~2 min", + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "PXFbY", + "name": "snd15", + "width": 44, + "height": 44, + "fill": "#CCCCCC", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "knItT", + "name": "sni15", + "width": 20, + "height": 20, + "iconFontName": "arrow-up", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + } + ] + } + ] + } + ] + } + ] + }, + { + "type": "text", + "id": "cw1CR", + "x": 0, + "y": 4609, + "name": "lbl16", + "fill": "#26262E", + "content": "16 — Switch to Manual Editing", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "RqbrN", + "x": 0, + "y": 4641, + "name": "16 — Switch Confirm", + "width": 1280, + "height": 800, + "fill": "#EFEFEF", + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "MgN34", + "name": "tb16", + "width": "fill_container", + "height": 48, + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "#DEDFE0" + }, + "padding": [ + 0, + 24 + ], + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DKlen", + "name": "tb16l", + "height": "fill_container", + "gap": 8, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "YvpJG", + "name": "tb16i", + "width": 20, + "height": 20, + "iconFontName": "chevron-left", + "iconFontFamily": "lucide", + "fill": "#C52D3A" + }, + { + "type": "text", + "id": "ssrC6", + "name": "tb16t", + "fill": "#C52D3A", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "text", + "id": "Y6O0G", + "name": "tb16c", + "fill": "#26262E", + "content": "My Journey", + "fontFamily": "Montserrat", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "evmIC", + "name": "tb16r", + "width": 20, + "height": 20 + } + ] + }, + { + "type": "frame", + "id": "34vT8", + "name": "bd16", + "width": "fill_container", + "height": "fill_container", + "children": [ + { + "type": "frame", + "id": "kpfG9", + "name": "cv16", + "width": 768, + "height": "fill_container", + "fill": "#F5F5F580", + "layout": "none", + "children": [ + { + "type": "frame", + "id": "xOK6d", + "x": 40, + "y": 100, + "name": "s16c1", + "opacity": 0.4, + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#1A1A2E", + "position": 0 + }, + { + "color": "#0F3460", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "XzuIm", + "name": "s16c1s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "Dz2Ev", + "name": "s16c1c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "r54LG", + "name": "s16c1h", + "fill": "#FFFFFF", + "content": "Welcome!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "AP3ag", + "x": 310, + "y": 100, + "name": "s16c2", + "opacity": 0.4, + "clip": true, + "width": 170, + "height": 290, + "fill": "#FFFFFF", + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "RtWkm", + "name": "s16c2c", + "width": "fill_container", + "height": "fill_container", + "layout": "vertical", + "gap": 10, + "padding": [ + 24, + 16, + 20, + 16 + ], + "justifyContent": "center", + "children": [ + { + "type": "text", + "id": "sjuCp", + "name": "s16c2h", + "fill": "#26262E", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "How did you\nhear about us?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + } + ] + } + ] + }, + { + "type": "frame", + "id": "KHwod", + "x": 580, + "y": 100, + "name": "s16c3", + "opacity": 0.4, + "clip": true, + "width": 170, + "height": 290, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 0, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#2D1B69", + "position": 0 + }, + { + "color": "#11998E", + "position": 1 + } + ] + }, + "cornerRadius": 16, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "eZH5J", + "name": "s16c3s", + "width": "fill_container", + "height": "fill_container" + }, + { + "type": "frame", + "id": "Qd5pz", + "name": "s16c3c", + "width": "fill_container", + "layout": "vertical", + "gap": 8, + "padding": [ + 16, + 16, + 20, + 16 + ], + "children": [ + { + "type": "text", + "id": "iaVtR", + "name": "s16c3h", + "fill": "#FFFFFF", + "content": "Thank You!", + "fontFamily": "Montserrat", + "fontSize": 18, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "zh10C", + "x": 768, + "y": 0, + "name": "cp16", + "enabled": false, + "width": "fill_container(512)", + "height": "fill_container(752)", + "fill": "#FFFFFF", + "stroke": { + "align": "inside", + "thickness": { + "left": 1 + }, + "fill": "#DEDFE0" + }, + "layout": "vertical" + } + ] + }, + { + "type": "rectangle", + "id": "CnNYb", + "layoutPosition": "absolute", + "x": 0, + "y": 48, + "name": "fullScrim", + "fill": "#00000020", + "width": 1280, + "height": 752 + }, + { + "type": "frame", + "id": "VcVN3", + "layoutPosition": "absolute", + "x": 450, + "y": 250, + "name": "chatBg", + "width": 380, + "layout": "vertical", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DJubV", + "name": "modal", + "width": 380, + "fill": "#FFFFFF", + "cornerRadius": 14, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#00000020", + "offset": { + "x": 0, + "y": 8 + }, + "blur": 32 + }, + "layout": "vertical", + "gap": 16, + "padding": [ + 24, + 24, + 20, + 24 + ], + "children": [ + { + "type": "frame", + "id": "eeMjM", + "name": "modalIcon", + "width": 40, + "height": 40, + "fill": "#FFF8F0", + "cornerRadius": 10, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "rqcsZ", + "name": "modalIconI", + "width": 20, + "height": 20, + "iconFontName": "log-out", + "iconFontFamily": "lucide", + "fill": "#F0720C" + } + ] + }, + { + "type": "text", + "id": "KcT1X", + "name": "modalTitle", + "fill": "#26262E", + "content": "Switch to manual editing?", + "fontFamily": "Montserrat", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "p3wag", + "name": "modalDesc", + "fill": "#6D6D7D", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Your chat history and undo points will be cleared. All changes you've made are already saved in the journey.", + "lineHeight": 1.5, + "fontFamily": "Open Sans", + "fontSize": 13, + "fontWeight": "normal" + }, + { + "type": "frame", + "id": "47u1x", + "name": "modalBtns", + "width": "fill_container", + "gap": 10, + "justifyContent": "end", + "children": [ + { + "type": "frame", + "id": "W5FzX", + "name": "modalCancel", + "cornerRadius": 10, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#DEDFE0" + }, + "padding": [ + 10, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XqBA9", + "name": "modalCancelT", + "fill": "#6D6D7D", + "content": "Stay in AI Editor", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "600" + } + ] + }, + { + "type": "frame", + "id": "fqm8E", + "name": "modalConfirm", + "fill": "#444451", + "cornerRadius": 10, + "gap": 6, + "padding": [ + 10, + 20 + ], + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "ySSQE", + "name": "modalConfirmI", + "width": 14, + "height": 14, + "iconFontName": "pencil", + "iconFontFamily": "lucide", + "fill": "#FFFFFF" + }, + { + "type": "text", + "id": "izP95", + "name": "modalConfirmT", + "fill": "#FFFFFF", + "content": "Edit Manually", + "fontFamily": "Montserrat", + "fontSize": 13, + "fontWeight": "700" + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7dd38c6ed6..db063a8c973 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@adobe/apollo-link-mutation-queue': specifier: ^1.1.0 version: 1.1.0(@types/react@19.2.2)(graphql-ws@6.0.6(graphql@16.10.0)(ws@8.18.1))(graphql@16.10.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(subscriptions-transport-ws@0.11.0(graphql@16.10.0)) + '@ai-sdk/anthropic': + specifier: ^3.0.58 + version: 3.0.63(zod@4.3.5) '@ai-sdk/google': specifier: ^3.0.43 version: 3.0.43(zod@4.3.5) @@ -56,6 +59,9 @@ importers: '@crowdin/ota-client': specifier: ^2.0.0 version: 2.0.2 + '@dagrejs/dagre': + specifier: ^2.0.4 + version: 2.0.4 '@datadog/browser-rum': specifier: ^6.14.0 version: 6.14.0 @@ -1308,6 +1314,12 @@ packages: '@adobe/css-tools@4.4.4': resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@ai-sdk/anthropic@3.0.63': + resolution: {integrity: sha512-SiLosFr0FfKfrNpAAj8mD/i3S5YBB/z5orb1DH3pN1yATuBNjjPMLnRE4P3Dn7Y5cQsro0uzw5g5117hkShWoQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@1.0.29': resolution: {integrity: sha512-o9LtmBiG2WAgs3GAmL79F8idan/UupxHG8Tyr2gP4aUSOzflM0bsvfzozBp8x6WatQnOx+Pio7YNw45Y6I16iw==} engines: {node: '>=18'} @@ -1374,6 +1386,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.21': + resolution: {integrity: sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@2.0.0': resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} engines: {node: '>=18'} @@ -3596,6 +3614,12 @@ packages: '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@dagrejs/dagre@2.0.4': + resolution: {integrity: sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==} + + '@dagrejs/graphlib@3.0.4': + resolution: {integrity: sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==} + '@datadog/browser-core@6.14.0': resolution: {integrity: sha512-ZQhLSw+mXxjyWoB0iWDJWncHIJZ4YGr0JMlPw/klK6SiFiqAwZNQu9Wkncch1m3zppgk36KiHKKOK/ThLBZDbg==} @@ -26769,6 +26793,12 @@ snapshots: '@adobe/css-tools@4.4.4': {} + '@ai-sdk/anthropic@3.0.63(zod@4.3.5)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.21(zod@4.3.5) + zod: 4.3.5 + '@ai-sdk/gateway@1.0.29(zod@3.25.67)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -26844,6 +26874,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 4.3.5 + '@ai-sdk/provider-utils@4.0.21(zod@4.3.5)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 4.3.5 + '@ai-sdk/provider@2.0.0': dependencies: json-schema: 0.4.0 @@ -31169,6 +31206,12 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 + '@dagrejs/dagre@2.0.4': + dependencies: + '@dagrejs/graphlib': 3.0.4 + + '@dagrejs/graphlib@3.0.4': {} + '@datadog/browser-core@6.14.0': {} '@datadog/browser-rum-core@6.14.0': @@ -33478,6 +33521,18 @@ snapshots: dependencies: tslib: 2.8.1 + '@formatjs/intl@2.10.0(typescript@5.4.4)': + dependencies: + '@formatjs/ecma402-abstract': 1.18.2 + '@formatjs/fast-memoize': 2.2.0 + '@formatjs/icu-messageformat-parser': 2.7.6 + '@formatjs/intl-displaynames': 6.6.6 + '@formatjs/intl-listformat': 7.5.5 + intl-messageformat: 10.5.11 + tslib: 2.8.1 + optionalDependencies: + typescript: 5.4.4 + '@formatjs/intl@2.10.0(typescript@5.9.3)': dependencies: '@formatjs/ecma402-abstract': 1.18.2 @@ -56959,7 +57014,7 @@ snapshots: dependencies: '@formatjs/ecma402-abstract': 1.18.2 '@formatjs/icu-messageformat-parser': 2.7.6 - '@formatjs/intl': 2.10.0(typescript@5.9.3) + '@formatjs/intl': 2.10.0(typescript@5.4.4) '@formatjs/intl-displaynames': 6.6.6 '@formatjs/intl-listformat': 7.5.5 '@types/hoist-non-react-statics': 3.3.6