diff --git a/apis/api-gateway/schema.graphql b/apis/api-gateway/schema.graphql index 6e44e126f5d..21cd03ec546 100644 --- a/apis/api-gateway/schema.graphql +++ b/apis/api-gateway/schema.graphql @@ -410,6 +410,7 @@ 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) + journeyAiEdit(input: JourneyAiEditInput!) : JourneyAiEditResult! @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) @@ -2388,6 +2389,11 @@ type GoogleSheetsSync @join__type(graph: API_JOURNEYS_MODERN) { journey: Journey! } +type JourneyAiEditResult @join__type(graph: API_JOURNEYS_MODERN) { + reply: String + proposedJourney: Json +} + type JourneyAiTranslateProgress @join__type(graph: API_JOURNEYS_MODERN) { """ Translation progress as a percentage (0-100) @@ -4803,6 +4809,13 @@ input IntegrationGoogleUpdateInput @join__type(graph: API_JOURNEYS_MODERN) { redirectUri: String! } +input JourneyAiEditInput @join__type(graph: API_JOURNEYS_MODERN) { + journeyId: ID! + message: String! + history: [MessageHistoryItem!] + selectedCardId: String +} + input JourneyAiTranslateInput @join__type(graph: API_JOURNEYS_MODERN) { journeyId: ID! name: String! @@ -4860,6 +4873,11 @@ input LinkActionInput @join__type(graph: API_JOURNEYS_MODERN) { parentStepId: String } +input MessageHistoryItem @join__type(graph: API_JOURNEYS_MODERN) { + role: String! + content: String! +} + input MultiselectBlockCreateInput @join__type(graph: API_JOURNEYS_MODERN) { id: ID journeyId: ID! diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index a45f630917e..74f54fd2dca 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -823,6 +823,18 @@ type Journey journeyCollections: [JourneyCollection!]! } +input JourneyAiEditInput { + journeyId: ID! + message: String! + history: [MessageHistoryItem!] + selectedCardId: String +} + +type JourneyAiEditResult { + reply: String + proposedJourney: Json +} + input JourneyAiTranslateInput { journeyId: ID! name: String! @@ -1233,6 +1245,11 @@ input LinkActionInput { union MediaVideo = MuxVideo | Video | YouTube +input MessageHistoryItem { + role: String! + content: String! +} + enum MessagePlatform { facebook telegram @@ -1393,6 +1410,7 @@ type Mutation { integrationGoogleCreate(input: IntegrationGoogleCreateInput!): IntegrationGoogle! integrationGoogleUpdate(id: ID!, input: IntegrationGoogleUpdateInput!): IntegrationGoogle! integrationDelete(id: ID!): Integration! + journeyAiEdit(input: JourneyAiEditInput!): JourneyAiEditResult! journeyAiTranslateCreate(input: JourneyAiTranslateInput!): Journey! createJourneyEventsExportLog(input: JourneyEventsExportLogInput!): JourneyEventsExportLog! journeyLanguageAiDetect(input: MutationJourneyLanguageAiDetectInput!): Boolean! diff --git a/apis/api-journeys-modern/src/schema/journey/simple/getSimpleJourney.ts b/apis/api-journeys-modern/src/schema/journey/simple/getSimpleJourney.ts index b5482661bb2..36102f7df8f 100644 --- a/apis/api-journeys-modern/src/schema/journey/simple/getSimpleJourney.ts +++ b/apis/api-journeys-modern/src/schema/journey/simple/getSimpleJourney.ts @@ -20,7 +20,10 @@ export async function getSimpleJourney( } } }) - if (!journey) throw new Error('Journey not found') + if (!journey) + throw new GraphQLError('Journey not found', { + extensions: { code: 'NOT_FOUND' } + }) const simpleJourney = simplifyJourney(journey) const result = journeySimpleSchema.safeParse(simpleJourney) diff --git a/apis/api-journeys-modern/src/schema/journeyAiEdit/index.ts b/apis/api-journeys-modern/src/schema/journeyAiEdit/index.ts new file mode 100644 index 00000000000..141b0da7aeb --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiEdit/index.ts @@ -0,0 +1 @@ +import './journeyAiEdit' diff --git a/apis/api-journeys-modern/src/schema/journeyAiEdit/journeyAiEdit.ts b/apis/api-journeys-modern/src/schema/journeyAiEdit/journeyAiEdit.ts new file mode 100644 index 00000000000..ede55993454 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiEdit/journeyAiEdit.ts @@ -0,0 +1,215 @@ +import { google } from '@ai-sdk/google' +import { ModelMessage, NoObjectGeneratedError, generateObject } from 'ai' +import { GraphQLError } from 'graphql' +import { z } from 'zod' + +import { prisma } from '@core/prisma/journeys/client' +import { + JourneySimple, + JourneySimpleCard, + journeySimpleSchema +} from '@core/shared/ai/journeySimpleTypes' +import { hardenPrompt } from '@core/shared/ai/prompts' + +import { Action, ability, subject } from '../../lib/auth/ability' +import { builder } from '../builder' +import { getSimpleJourney } from '../journey/simple/getSimpleJourney' + +import { buildSystemPrompt } from './prompts' + +// Return type for the mutation +interface JourneyAiEditResult { + reply: string + proposedJourney: JourneySimple | null +} + +const JourneyAiEditResultRef = builder.objectRef( + 'JourneyAiEditResult' +) + +builder.objectType(JourneyAiEditResultRef, { + fields: (t) => ({ + reply: t.string({ + resolve: (parent) => parent.reply + }), + proposedJourney: t.field({ + type: 'Json', + nullable: true, + resolve: (parent) => parent.proposedJourney + }) + }) +}) + +const MessageHistoryItem = builder.inputType('MessageHistoryItem', { + fields: (t) => ({ + role: t.string({ required: true }), + content: t.string({ required: true }) + }) +}) + +// Input type +const JourneyAiEditInput = builder.inputType('JourneyAiEditInput', { + fields: (t) => ({ + journeyId: t.id({ required: true }), + message: t.string({ required: true }), + history: t.field({ type: [MessageHistoryItem], required: false }), + selectedCardId: t.string({ required: false }) + }) +}) + +// Use the base schema (no superRefine) so Zod validation doesn't reject +// model output that has minor issues (e.g. empty video urls on non-video cards). +// We sanitize the output ourselves after generation. +const journeyAiEditSchema = z.object({ + reply: z + .string() + .describe( + 'Plain language explanation of what was changed and why, or suggestions/answer if no changes' + ), + journey: journeySimpleSchema + .nullable() + .describe( + 'Full updated journey with all changes applied, or null if no structural changes are needed' + ) +}) + +// Strip video fields with empty URLs that the model sometimes emits on non-video cards +function sanitizeJourney(journey: JourneySimple): JourneySimple { + return { + ...journey, + cards: journey.cards.map((card): JourneySimpleCard => { + if (card.video != null && !card.video.url) { + const { video: _video, ...rest } = card + return rest + } + return card + }) + } +} + +builder.mutationField('journeyAiEdit', (t) => + t.withAuth({ isAuthenticated: true }).field({ + type: JourneyAiEditResultRef, + nullable: false, + args: { + input: t.arg({ + type: JourneyAiEditInput, + required: true + }) + }, + resolve: async (_parent, { input }, context) => { + // 1. Validate message length + if (input.message.length > 2000) { + throw new GraphQLError( + 'Message exceeds maximum length of 2000 characters', + { + extensions: { code: 'BAD_USER_INPUT' } + } + ) + } + + // 2. Fetch journey and validate ACL + const dbJourney = await prisma.journey.findUnique({ + where: { id: input.journeyId }, + include: { + userJourneys: true, + team: { include: { userTeams: true } } + } + }) + + if (!dbJourney) { + throw new GraphQLError('journey not found', { + extensions: { code: 'NOT_FOUND' } + }) + } + + if ( + !ability(Action.Update, subject('Journey', dbJourney), context.user) + ) { + throw new GraphQLError( + 'user does not have permission to update journey', + { extensions: { code: 'FORBIDDEN' } } + ) + } + + // 3. Fetch simple journey representation + let currentJourney: JourneySimple + try { + currentJourney = await getSimpleJourney(input.journeyId) + } catch (error) { + if (error instanceof GraphQLError) throw error + console.error('journeyAiEdit: failed to load journey', error) + throw new GraphQLError( + 'Failed to load journey data. Please try again.', + { + extensions: { code: 'INTERNAL_SERVER_ERROR' } + } + ) + } + + // 4. Prune history to last 10 turns + const prunedHistory = (input.history ?? []).slice(-10).map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content + })) + + // 5. Harden user message + const hardenedMessage = hardenPrompt(input.message) + + // 6. Build system prompt + const systemPrompt = buildSystemPrompt( + currentJourney, + input.selectedCardId ?? undefined + ) + + // 7. Call generateObject + let aiResult: z.infer + try { + const { object } = await generateObject({ + model: google('gemini-2.0-flash'), + system: systemPrompt, + messages: [ + ...prunedHistory, + { role: 'user', content: hardenedMessage } + ], + schema: journeyAiEditSchema, + maxRetries: 2, + abortSignal: AbortSignal.timeout(30_000) + }) + aiResult = object + } catch (error) { + if (error instanceof NoObjectGeneratedError) { + console.error('journeyAiEdit: NoObjectGeneratedError', { + journeyId: input.journeyId, + rawOutput: error.text + }) + return { + reply: + 'Something went wrong generating a response. Please try rephrasing your request.', + proposedJourney: null + } + } + console.error('journeyAiEdit: generateObject error', error) + return { + reply: 'Something went wrong. Please try again.', + proposedJourney: null + } + } + + // 8. Audit log + console.log('journeyAiEdit audit', { + userId: context.user.id, + journeyId: input.journeyId, + timestamp: new Date().toISOString(), + hadProposal: aiResult.journey != null + }) + + // 9. Return result + const rawJourney = aiResult.journey + return { + reply: aiResult.reply, + proposedJourney: rawJourney != null ? sanitizeJourney(rawJourney) : null + } + } + }) +) diff --git a/apis/api-journeys-modern/src/schema/journeyAiEdit/prompts.ts b/apis/api-journeys-modern/src/schema/journeyAiEdit/prompts.ts new file mode 100644 index 00000000000..f7de5222728 --- /dev/null +++ b/apis/api-journeys-modern/src/schema/journeyAiEdit/prompts.ts @@ -0,0 +1,74 @@ +import { JourneySimple } from '@core/shared/ai/journeySimpleTypes' + +export function buildSystemPrompt( + journey: JourneySimple, + selectedCardId?: string +): string { + const screenCount = journey.cards.length + + // Find selected card if provided + const selectedCard = selectedCardId + ? journey.cards.find((c) => c.id === selectedCardId) + : undefined + const selectedIndex = selectedCard + ? journey.cards.indexOf(selectedCard) + 1 + : undefined + + let prompt = `You are an AI assistant that helps users edit journey content. + +IMPORTANT LANGUAGE RULES: +- NEVER use technical terms like "block", "step", "card", "StepBlock", "CardBlock", or any internal type names +- Say "screen" or "slide" when referring to a section of the journey +- Say "intro screen", "first screen", "last screen", etc. for positions +- Describe changes in plain language: "I've updated your intro text" not "I modified card-1" +- Reply in the same language the user writes in + +OUTPUT CONTRACT: +- When making changes, return the COMPLETE updated journey JSON in the journey field +- When only giving advice or answering questions, set journey to null +- Always include a clear, plain-language explanation in the reply field + +CURRENT JOURNEY STATE: +Title: ${journey.title} +Description: ${journey.description} +Screens: ${screenCount} ${screenCount === 1 ? 'screen' : 'screens'} + +Full journey JSON (for your internal use — never mention this structure to users): +${JSON.stringify(journey, null, 2)} +` + + if (selectedCard !== undefined && selectedIndex !== undefined) { + const heading = selectedCard.heading ?? '(no heading)' + prompt += ` +SELECTED SCREEN CONTEXT: +The user is currently viewing screen ${selectedIndex}: "${heading}". +References to "this screen", "here", "the current one", or "this slide" mean this specific screen. +` + } + + prompt += ` +CONTENT TYPES AVAILABLE: +- Title/heading text +- Body text +- Image (displayed on the screen) +- Background image (fills the entire screen background) +- Video (YouTube URL — if a screen has a video, it can only contain a video and a next-screen link) +- Button (navigates to next screen or external URL) +- Multiple-choice poll (each option navigates to a specific screen or URL) + +CONSTRAINTS: +- A screen with a video can ONLY have a video and a link to the next screen — no heading, text, buttons, or polls +- Every screen must have a way to go to the next screen (button, poll options, video's defaultNextCard, or defaultNextCard) +- When returning a full updated journey, include ALL screens — not just the changed ones +- Journey cards must always have valid navigation so users can always progress + +BEHAVIORAL RULES: +- Act on additive and editing requests immediately +- Ask for confirmation before bulk deletions (e.g., "delete all screens") +- If a journey is empty (no screens), offer to add an introduction screen +- Describe what you changed in plain terms +- Do not mention internal IDs or field names to users +` + + return prompt +} diff --git a/apis/api-journeys-modern/src/schema/schema.ts b/apis/api-journeys-modern/src/schema/schema.ts index 509d23220cc..5259b5cb3fa 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 './journeyAiEdit' import './journeyAiTranslate' import './journeyCollection' import './journeyEventsExportLog' diff --git a/apps/journeys-admin/__generated__/GetAiEditorJourney.ts b/apps/journeys-admin/__generated__/GetAiEditorJourney.ts new file mode 100644 index 00000000000..e53516ca024 --- /dev/null +++ b/apps/journeys-admin/__generated__/GetAiEditorJourney.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: GetAiEditorJourney +// ==================================================== + +export interface GetAiEditorJourney_journey { + __typename: "Journey"; + id: string; + /** + * private title for creators + */ + title: string; + description: string | null; +} + +export interface GetAiEditorJourney { + journey: GetAiEditorJourney_journey; +} + +export interface GetAiEditorJourneyVariables { + id: string; +} diff --git a/apps/journeys-admin/__generated__/GetJourneySimpleForAiEditor.ts b/apps/journeys-admin/__generated__/GetJourneySimpleForAiEditor.ts new file mode 100644 index 00000000000..798a6da2f3d --- /dev/null +++ b/apps/journeys-admin/__generated__/GetJourneySimpleForAiEditor.ts @@ -0,0 +1,16 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: GetJourneySimpleForAiEditor +// ==================================================== + +export interface GetJourneySimpleForAiEditor { + journeySimpleGet: any | null; +} + +export interface GetJourneySimpleForAiEditorVariables { + id: string; +} diff --git a/apps/journeys-admin/__generated__/GoogleSheetsSyncsForDoneScreen.ts b/apps/journeys-admin/__generated__/GoogleSheetsSyncsForDoneScreen.ts new file mode 100644 index 00000000000..d5a7147e851 --- /dev/null +++ b/apps/journeys-admin/__generated__/GoogleSheetsSyncsForDoneScreen.ts @@ -0,0 +1,24 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { GoogleSheetsSyncsFilter } from "./globalTypes"; + +// ==================================================== +// GraphQL query operation: GoogleSheetsSyncsForDoneScreen +// ==================================================== + +export interface GoogleSheetsSyncsForDoneScreen_googleSheetsSyncs { + __typename: "GoogleSheetsSync"; + id: string; + deletedAt: any | null; +} + +export interface GoogleSheetsSyncsForDoneScreen { + googleSheetsSyncs: GoogleSheetsSyncsForDoneScreen_googleSheetsSyncs[]; +} + +export interface GoogleSheetsSyncsForDoneScreenVariables { + filter: GoogleSheetsSyncsFilter; +} diff --git a/apps/journeys-admin/__generated__/JourneyAiEdit.ts b/apps/journeys-admin/__generated__/JourneyAiEdit.ts new file mode 100644 index 00000000000..95fc7a91a2f --- /dev/null +++ b/apps/journeys-admin/__generated__/JourneyAiEdit.ts @@ -0,0 +1,24 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { JourneyAiEditInput } from "./globalTypes"; + +// ==================================================== +// GraphQL mutation operation: JourneyAiEdit +// ==================================================== + +export interface JourneyAiEdit_journeyAiEdit { + __typename: "JourneyAiEditResult"; + reply: string | null; + proposedJourney: any | null; +} + +export interface JourneyAiEdit { + journeyAiEdit: JourneyAiEdit_journeyAiEdit; +} + +export interface JourneyAiEditVariables { + input: JourneyAiEditInput; +} diff --git a/apps/journeys-admin/__generated__/JourneySimpleUpdateFromAiEditor.ts b/apps/journeys-admin/__generated__/JourneySimpleUpdateFromAiEditor.ts new file mode 100644 index 00000000000..b50da9abd5f --- /dev/null +++ b/apps/journeys-admin/__generated__/JourneySimpleUpdateFromAiEditor.ts @@ -0,0 +1,17 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL mutation operation: JourneySimpleUpdateFromAiEditor +// ==================================================== + +export interface JourneySimpleUpdateFromAiEditor { + journeySimpleUpdate: any | null; +} + +export interface JourneySimpleUpdateFromAiEditorVariables { + id: string; + journey: any; +} diff --git a/apps/journeys-admin/__generated__/globalTypes.ts b/apps/journeys-admin/__generated__/globalTypes.ts index 47d4d4d4700..9fc6dfbd5c8 100644 --- a/apps/journeys-admin/__generated__/globalTypes.ts +++ b/apps/journeys-admin/__generated__/globalTypes.ts @@ -580,6 +580,13 @@ export interface IntegrationGrowthSpacesUpdateInput { accessSecret: string; } +export interface JourneyAiEditInput { + journeyId: string; + message: string; + history?: MessageHistoryItem[] | null; + selectedCardId?: string | null; +} + export interface JourneyCollectionCreateInput { id?: string | null; teamId: string; @@ -738,6 +745,11 @@ export interface MeInput { app?: App | null; } +export interface MessageHistoryItem { + role: string; + content: string; +} + export interface MultiselectBlockCreateInput { id?: string | null; journeyId: string; diff --git a/apps/journeys-admin/pages/journeys/[journeyId]/ai.tsx b/apps/journeys-admin/pages/journeys/[journeyId]/ai.tsx new file mode 100644 index 00000000000..ed58c6234a1 --- /dev/null +++ b/apps/journeys-admin/pages/journeys/[journeyId]/ai.tsx @@ -0,0 +1,242 @@ +import { gql, useQuery } from '@apollo/client' +import Box from '@mui/material/Box' +import CircularProgress from '@mui/material/CircularProgress' +import Typography from '@mui/material/Typography' +import { GetServerSidePropsContext } from 'next' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import { NextSeo } from 'next-seo' +import { ReactElement, useCallback, useEffect, useState } from 'react' + +import { JourneyProvider } from '@core/journeys/ui/JourneyProvider' +import { useJourneyQuery } from '@core/journeys/ui/useJourneyQuery' +import { JourneySimple } from '@core/shared/ai/journeySimpleTypes' + +import { + GetAiEditorJourney, + GetAiEditorJourneyVariables +} from '../../../__generated__/GetAiEditorJourney' +import { + GetJourneySimpleForAiEditor, + GetJourneySimpleForAiEditorVariables +} from '../../../__generated__/GetJourneySimpleForAiEditor' +import { IdType } from '../../../__generated__/globalTypes' +import { AiChat, AiState } from '../../../src/components/AiEditor/AiChat/AiChat' +import { AiEditorHeader } from '../../../src/components/AiEditor/AiEditorHeader' +import { AiEditorPreview } from '../../../src/components/AiEditor/AiEditorPreview' +import { + getAuthTokens, + redirectToLogin, + toUser +} from '../../../src/libs/auth/getAuthTokens' +import { initAndAuthApp } from '../../../src/libs/initAndAuthApp' + +const GET_AI_EDITOR_JOURNEY = gql` + query GetAiEditorJourney($id: ID!) { + journey: adminJourney(id: $id, idType: databaseId) { + id + title + description + } + } +` + +const GET_JOURNEY_SIMPLE_FOR_AI_EDITOR = gql` + query GetJourneySimpleForAiEditor($id: ID!) { + journeySimpleGet(id: $id) + } +` + +function AiEditorPage(): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + const router = useRouter() + const journeyId = router.query.journeyId as string + + const [currentJourney, setCurrentJourney] = useState( + null + ) + const [proposedJourney, setProposedJourney] = useState( + null + ) + const [aiState, setAiState] = useState({ + status: 'idle', + affectedCardIds: [] + }) + const [selectedCardId, setSelectedCardId] = useState(null) + + const { data, loading } = useQuery< + GetAiEditorJourney, + GetAiEditorJourneyVariables + >(GET_AI_EDITOR_JOURNEY, { + variables: { id: journeyId }, + skip: journeyId == null + }) + + const { data: simpleData } = useQuery< + GetJourneySimpleForAiEditor, + GetJourneySimpleForAiEditorVariables + >(GET_JOURNEY_SIMPLE_FOR_AI_EDITOR, { + variables: { id: journeyId }, + skip: journeyId == null + }) + + const { data: journeyData, refetch: refetchJourney } = useJourneyQuery({ + id: journeyId, + idType: IdType.databaseId, + options: { skipRoutingFilter: true } + }) + + // Populate currentJourney from the initial fetch + useEffect(() => { + if (simpleData?.journeySimpleGet != null && currentJourney == null) { + setCurrentJourney(simpleData.journeySimpleGet as JourneySimple) + } + }, [simpleData, currentJourney]) + + const journey = data?.journey + + const previewJourney = proposedJourney ?? currentJourney + + const selectedCardIndex = + previewJourney != null && selectedCardId != null + ? previewJourney.cards.findIndex((c) => c.id === selectedCardId) + 1 + : null + + const handleJourneyUpdated = useCallback( + (updatedJourney: JourneySimple) => { + setCurrentJourney(updatedJourney) + setAiState({ status: 'idle', affectedCardIds: [] }) + void refetchJourney() + }, + [refetchJourney] + ) + + const handleProposedJourney = useCallback((journey: JourneySimple | null) => { + setProposedJourney(journey) + }, []) + + const handleSelectedCardChange = useCallback((cardId: string | null) => { + setSelectedCardId(cardId) + }, []) + + const handleClearSelectedCard = useCallback(() => { + setSelectedCardId(null) + }, []) + + if (loading || journeyId == null) { + return ( + + + + ) + } + + return ( + <> + + + + + 0 + ? selectedCardIndex + : null + } + onClearSelectedCard={handleClearSelectedCard} + onAiState={setAiState} + onProposedJourney={handleProposedJourney} + onJourneyUpdated={handleJourneyUpdated} + sx={{ + width: { xs: '100%', md: '40%' }, + display: { + xs: selectedCardId != null ? 'none' : 'flex', + md: 'flex' + } + }} + /> + + {previewJourney != null ? ( + + ) : ( + + + + )} + + + + + ) +} + +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 { flags, redirect, translations } = await initAndAuthApp({ + user, + locale: ctx.locale, + resolvedUrl: ctx.resolvedUrl + }) + + if (redirect != null) return { redirect } + + return { + props: { + userSerialized: JSON.stringify(user), + ...translations, + flags + } + } +} + +export default AiEditorPage diff --git a/apps/journeys-admin/src/components/AiEditor/AiChat/AiChat.tsx b/apps/journeys-admin/src/components/AiEditor/AiChat/AiChat.tsx new file mode 100644 index 00000000000..8fcdd4ab276 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChat/AiChat.tsx @@ -0,0 +1,476 @@ +import { gql, useMutation } from '@apollo/client' +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' +import Box from '@mui/material/Box' +import CircularProgress from '@mui/material/CircularProgress' +import { SxProps } from '@mui/material/styles' +import Typography from '@mui/material/Typography' +import { useTranslation } from 'next-i18next' +import { ReactElement, useCallback, useEffect, useReducer, useRef } from 'react' + +import { JourneySimple } from '@core/shared/ai/journeySimpleTypes' + +import { + JourneyAiEdit, + JourneyAiEditVariables +} from '../../../../__generated__/JourneyAiEdit' +import { + JourneySimpleUpdateFromAiEditor, + JourneySimpleUpdateFromAiEditorVariables +} from '../../../../__generated__/JourneySimpleUpdateFromAiEditor' + +import { AiChatInput } from './AiChatInput' +import { AiChatMessage, ChatMessage } from './AiChatMessage' + +export const JOURNEY_AI_EDIT = gql` + mutation JourneyAiEdit($input: JourneyAiEditInput!) { + journeyAiEdit(input: $input) { + reply + proposedJourney + } + } +` + +export const JOURNEY_SIMPLE_UPDATE = gql` + mutation JourneySimpleUpdateFromAiEditor($id: ID!, $journey: Json!) { + journeySimpleUpdate(id: $id, journey: $journey) + } +` + +export interface AiState { + status: 'idle' | 'loading' | 'proposal' | 'error' + affectedCardIds: string[] +} + +interface AiChatProps { + journeyId: string + currentJourney: JourneySimple | null + selectedCardId?: string | null + selectedCardIndex?: number | null + onClearSelectedCard?: () => void + onAiState: (state: AiState) => void + onProposedJourney: (journey: JourneySimple | null) => void + onJourneyUpdated: (journey: JourneySimple) => void + sx?: SxProps +} + +interface AiChatState { + status: 'idle' | 'loading' | 'error' + messages: ChatMessage[] + generationId: number + inputValue: string + errorMessage: string | null + applyingMessageId: number | null +} + +type AiChatAction = + | { type: 'SEND'; message: string } + | { + type: 'RECEIVE' + reply: string + proposedJourney: JourneySimple | null + diffSummary: string[] + } + | { type: 'ERROR'; errorMessage: string } + | { type: 'INPUT_CHANGE'; value: string } + | { type: 'APPLY_START'; messageGenerationId: number } + | { type: 'APPLY_SUCCESS'; messageGenerationId: number } + | { type: 'APPLY_ERROR'; messageGenerationId: number } + | { type: 'DISMISS'; messageGenerationId: number } + +function reducer(state: AiChatState, action: AiChatAction): AiChatState { + switch (action.type) { + case 'SEND': { + const userMessage: ChatMessage = { + role: 'user', + content: action.message, + generationId: state.generationId + 1 + } + return { + ...state, + status: 'loading', + messages: [...state.messages, userMessage], + generationId: state.generationId + 1, + inputValue: '', + errorMessage: null + } + } + case 'RECEIVE': { + const assistantMessage: ChatMessage = { + role: 'assistant', + content: action.reply, + proposedJourney: action.proposedJourney ?? undefined, + diffSummary: action.diffSummary, + generationId: state.generationId + } + return { + ...state, + status: 'idle', + messages: [...state.messages, assistantMessage] + } + } + case 'ERROR': { + return { + ...state, + status: 'idle', + errorMessage: action.errorMessage + } + } + case 'INPUT_CHANGE': { + return { ...state, inputValue: action.value } + } + case 'APPLY_START': { + return { ...state, applyingMessageId: action.messageGenerationId } + } + case 'APPLY_SUCCESS': { + return { + ...state, + applyingMessageId: null, + generationId: state.generationId + 1, + messages: state.messages.map((m) => + m.generationId === action.messageGenerationId + ? { ...m, applied: true } + : m + ) + } + } + case 'APPLY_ERROR': { + return { ...state, applyingMessageId: null } + } + case 'DISMISS': { + return { + ...state, + messages: state.messages.map((m) => + m.generationId === action.messageGenerationId + ? { ...m, dismissed: true } + : m + ) + } + } + default: + return state + } +} + +function describeJourneyDiff( + current: JourneySimple | null, + proposed: JourneySimple +): { affectedCardIds: string[]; summary: string[] } { + if (current == null) { + return { + affectedCardIds: proposed.cards.map((c) => c.id), + summary: ['Journey updated'] + } + } + + const affectedCardIds: string[] = [] + const summary: string[] = [] + + const currentMap = new Map(current.cards.map((c) => [c.id, c])) + const proposedMap = new Map(proposed.cards.map((c) => [c.id, c])) + + for (const proposedCard of proposed.cards) { + const currentCard = currentMap.get(proposedCard.id) + if (currentCard == null) { + affectedCardIds.push(proposedCard.id) + const idx = proposed.cards.indexOf(proposedCard) + 1 + summary.push(`New screen added at position ${idx}`) + } else if (JSON.stringify(currentCard) !== JSON.stringify(proposedCard)) { + affectedCardIds.push(proposedCard.id) + const idx = proposed.cards.indexOf(proposedCard) + 1 + summary.push(`Screen ${idx} updated`) + } + } + + for (const currentCard of current.cards) { + if (!proposedMap.has(currentCard.id)) { + const idx = current.cards.indexOf(currentCard) + 1 + summary.push(`Screen ${idx} removed`) + } + } + + if (current.title !== proposed.title) { + summary.push(`Title updated to "${proposed.title}"`) + } + + return { affectedCardIds, summary } +} + +const SUGGESTED_PROMPTS = [ + 'Add an introduction screen at the beginning', + 'Make the call-to-action more compelling', + 'What could I improve about this journey?' +] + +export function AiChat({ + journeyId, + currentJourney, + selectedCardId, + selectedCardIndex, + onClearSelectedCard, + onAiState, + onProposedJourney, + onJourneyUpdated, + sx +}: AiChatProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + const messagesEndRef = useRef(null) + + const [state, dispatch] = useReducer(reducer, { + status: 'idle', + messages: [], + generationId: 0, + inputValue: '', + errorMessage: null, + applyingMessageId: null + }) + + const [journeyAiEdit] = useMutation( + JOURNEY_AI_EDIT + ) + const [journeySimpleUpdate] = useMutation< + JourneySimpleUpdateFromAiEditor, + JourneySimpleUpdateFromAiEditorVariables + >(JOURNEY_SIMPLE_UPDATE) + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + }, [state.messages]) + + const handleSend = useCallback(async () => { + if (state.status !== 'idle' || state.inputValue.trim().length === 0) return + + const message = state.inputValue.trim() + dispatch({ type: 'SEND', message }) + + const history = state.messages.slice(-10).map((m) => ({ + role: m.role, + content: m.content + })) + + onAiState({ + status: 'loading', + affectedCardIds: selectedCardId != null ? [selectedCardId] : [] + }) + + try { + const result = await journeyAiEdit({ + variables: { + input: { + journeyId, + message, + history, + selectedCardId: selectedCardId ?? null + } + } + }) + + const data = result.data?.journeyAiEdit + if (data == null) throw new Error('No response from AI') + + const proposedJourney = data.proposedJourney as JourneySimple | null + const diff = + proposedJourney != null + ? describeJourneyDiff(currentJourney, proposedJourney) + : { affectedCardIds: [], summary: [] } + + dispatch({ + type: 'RECEIVE', + reply: data.reply ?? '', + proposedJourney, + diffSummary: diff.summary + }) + + onProposedJourney(proposedJourney) + onAiState({ + status: proposedJourney != null ? 'proposal' : 'idle', + affectedCardIds: diff.affectedCardIds + }) + } catch { + dispatch({ + type: 'ERROR', + errorMessage: 'Failed to get a response. Please try again.' + }) + onAiState({ status: 'idle', affectedCardIds: [] }) + } + }, [ + state.status, + state.inputValue, + state.messages, + journeyId, + selectedCardId, + currentJourney, + journeyAiEdit, + onProposedJourney, + onAiState + ]) + + const handleApply = useCallback( + async (message: ChatMessage) => { + if (message.proposedJourney == null) return + if (message.generationId !== state.generationId) return + + dispatch({ + type: 'APPLY_START', + messageGenerationId: message.generationId + }) + + try { + await journeySimpleUpdate({ + variables: { + id: journeyId, + journey: message.proposedJourney + } + }) + dispatch({ + type: 'APPLY_SUCCESS', + messageGenerationId: message.generationId + }) + onProposedJourney(null) + onJourneyUpdated(message.proposedJourney) + onAiState({ status: 'idle', affectedCardIds: [] }) + } catch { + dispatch({ + type: 'APPLY_ERROR', + messageGenerationId: message.generationId + }) + } + }, + [ + state.generationId, + journeyId, + journeySimpleUpdate, + onProposedJourney, + onJourneyUpdated, + onAiState + ] + ) + + const handleDismiss = useCallback( + (message: ChatMessage) => { + dispatch({ type: 'DISMISS', messageGenerationId: message.generationId }) + onProposedJourney(null) + onAiState({ status: 'idle', affectedCardIds: [] }) + }, + [onProposedJourney, onAiState] + ) + + const isEmpty = state.messages.length === 0 + + return ( + + {/* Header */} + + + {t('AI Editor')} + + + {/* Messages area */} + + {isEmpty ? ( + + + + {t('Describe changes to your journey in plain language')} + + + {SUGGESTED_PROMPTS.map((prompt) => ( + + dispatch({ type: 'INPUT_CHANGE', value: prompt }) + } + sx={{ + cursor: 'pointer', + p: 1, + borderRadius: 1, + border: 1, + borderColor: 'divider', + color: 'text.secondary', + '&:hover': { bgcolor: 'action.hover' } + }} + > + {prompt} + + ))} + + + ) : ( + state.messages.map((message, i) => ( + + )) + )} + {state.status === 'loading' && ( + + + + {t('Thinking...')} + + + )} + {state.errorMessage != null && ( + + {state.errorMessage} + + )} +
+ + + {/* Input */} + dispatch({ type: 'INPUT_CHANGE', value })} + onSend={handleSend} + disabled={state.status === 'loading'} + selectedCardId={selectedCardId} + selectedCardIndex={selectedCardIndex} + onClearSelectedCard={onClearSelectedCard} + /> + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiChat/AiChatInput.tsx b/apps/journeys-admin/src/components/AiEditor/AiChat/AiChatInput.tsx new file mode 100644 index 00000000000..f6683ce91aa --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChat/AiChatInput.tsx @@ -0,0 +1,98 @@ +import SendRoundedIcon from '@mui/icons-material/SendRounded' +import Box from '@mui/material/Box' +import Chip from '@mui/material/Chip' +import IconButton from '@mui/material/IconButton' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useTranslation } from 'next-i18next' +import { KeyboardEvent, ReactElement } from 'react' + +interface AiChatInputProps { + value: string + onChange: (value: string) => void + onSend: () => void + disabled: boolean + selectedCardId?: string | null + selectedCardIndex?: number | null + onClearSelectedCard?: () => void +} + +const MAX_LENGTH = 2000 + +export function AiChatInput({ + value, + onChange, + onSend, + disabled, + selectedCardId, + selectedCardIndex, + onClearSelectedCard +}: AiChatInputProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + const charsLeft = MAX_LENGTH - value.length + const nearLimit = charsLeft < 200 + + function handleKeyDown(e: KeyboardEvent): void { + if ( + (e.metaKey || e.ctrlKey) && + e.key === 'Enter' && + !disabled && + value.trim().length > 0 + ) { + e.preventDefault() + onSend() + } + } + + return ( + + {selectedCardId != null && selectedCardIndex != null && ( + + + + )} + + onChange(e.target.value)} + onKeyDown={handleKeyDown} + disabled={disabled} + inputProps={{ maxLength: MAX_LENGTH }} + size="small" + /> + + + + + {nearLimit && ( + + {t('{{n}} characters remaining', { n: charsLeft })} + + )} + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiChat/AiChatMessage.tsx b/apps/journeys-admin/src/components/AiEditor/AiChat/AiChatMessage.tsx new file mode 100644 index 00000000000..36f9e665614 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiChat/AiChatMessage.tsx @@ -0,0 +1,173 @@ +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import CircularProgress from '@mui/material/CircularProgress' +import Divider from '@mui/material/Divider' +import Paper from '@mui/material/Paper' +import Typography from '@mui/material/Typography' +import { useTranslation } from 'next-i18next' +import { ReactElement } from 'react' + +import { JourneySimple } from '@core/shared/ai/journeySimpleTypes' + +export interface ChatMessage { + role: 'user' | 'assistant' + content: string + proposedJourney?: JourneySimple + generationId: number + applied?: boolean + dismissed?: boolean + diffSummary?: string[] +} + +interface AiChatMessageProps { + message: ChatMessage + currentGenerationId: number + onApply: (message: ChatMessage) => void + onDismiss: (message: ChatMessage) => void + applying?: boolean +} + +export function AiChatMessage({ + message, + currentGenerationId, + onApply, + onDismiss, + applying +}: AiChatMessageProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + const isUser = message.role === 'user' + const isStale = message.generationId !== currentGenerationId + const hasProposal = + message.proposedJourney != null && !message.applied && !message.dismissed + + return ( + + + + {!isUser && ( + + + + {t('AI Assistant')} + + + )} + + {message.content} + + + + {hasProposal && ( + + {isStale ? ( + + {t('This proposal is outdated')} + + ) : ( + <> + + + {t('Proposed changes')} + + {message.diffSummary != null && + message.diffSummary.length > 0 && ( + + {message.diffSummary.map((item, i) => ( + + • {item} + + ))} + + )} + + + + + + + )} + + )} + + {message.applied === true && ( + + {t('✓ Applied')} + + )} + {message.dismissed === true && ( + + {t('Dismissed')} + + )} + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiEditorCardPreview.tsx b/apps/journeys-admin/src/components/AiEditor/AiEditorCardPreview.tsx new file mode 100644 index 00000000000..c01e71c4a25 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiEditorCardPreview.tsx @@ -0,0 +1,131 @@ +import Box from '@mui/material/Box' +import { SxProps } from '@mui/material/styles' +import Typography from '@mui/material/Typography' +import { useTranslation } from 'next-i18next' +import { ReactElement, useMemo } from 'react' + +import { TreeBlock } from '@core/journeys/ui/block' +import { BlockRenderer } from '@core/journeys/ui/BlockRenderer' +import { FramePortal } from '@core/journeys/ui/FramePortal' +import { getStepTheme } from '@core/journeys/ui/getStepTheme' +import { useJourney } from '@core/journeys/ui/JourneyProvider' +import { getJourneyRTL } from '@core/journeys/ui/rtl' +import { StepFields } from '@core/journeys/ui/Step/__generated__/StepFields' +import { transformer } from '@core/journeys/ui/transformer' +import { ThemeProvider } from '@core/shared/ui/ThemeProvider' + +import { AiState } from './AiChat/AiChat' + +// Card natural dimensions (same as Canvas.tsx) +const CARD_WIDTH = 324 +const CARD_HEIGHT = 674 + +interface AiEditorCardPreviewProps { + selectedCardId: string | null + aiState: AiState + sx?: SxProps +} + +export function AiEditorCardPreview({ + selectedCardId, + aiState, + sx +}: AiEditorCardPreviewProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + const { journey } = useJourney() + + const steps = useMemo( + () => transformer(journey?.blocks ?? []) as Array>, + [journey?.blocks] + ) + + // Map "card-N" → steps[N-1] + const selectedStep = useMemo(() => { + if (selectedCardId == null) return null + const match = /^card-(\d+)$/.exec(selectedCardId) + if (match == null) return null + const index = parseInt(match[1], 10) - 1 + return steps[index] ?? null + }, [selectedCardId, steps]) + + const { rtl, locale } = getJourneyRTL(journey) + + const theme = + selectedStep != null ? getStepTheme(selectedStep, journey) : null + + const isAffected = + selectedCardId != null && aiState.affectedCardIds.includes(selectedCardId) + + // Scale the 324×674 card to fit the container + const scale = 0.42 + + return ( + + {selectedStep == null || theme == null ? ( + + {t('Click a screen in the map below to preview it')} + + ) : ( + + + + {() => ( + + + + )} + + + {isAffected && aiState.status === 'proposal' && ( + + {t('AI CHANGE')} + + )} + + )} + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiEditorFlowMap.tsx b/apps/journeys-admin/src/components/AiEditor/AiEditorFlowMap.tsx new file mode 100644 index 00000000000..f94104c8bbe --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiEditorFlowMap.tsx @@ -0,0 +1,229 @@ +import Box from '@mui/material/Box' +import Paper from '@mui/material/Paper' +import { SxProps, useTheme } from '@mui/material/styles' +import Typography from '@mui/material/Typography' +import { MouseEvent, ReactElement, memo, useMemo } from 'react' +import { + Background, + Edge, + Handle, + Node, + NodeProps, + Position, + ReactFlow, + ReactFlowProvider +} from 'reactflow' +import 'reactflow/dist/style.css' + +import { JourneySimple } from '@core/shared/ai/journeySimpleTypes' + +import { AiState } from './AiChat/AiChat' + +const NODE_WIDTH = 160 +const NODE_HEIGHT = 60 + +interface AiCardNodeData { + cardIndex: number + label: string + isAffected: boolean + isLoading: boolean + isSelected: boolean +} + +const AiCardNode = memo( + ({ data, id }: NodeProps): ReactElement => { + const { label, cardIndex, isAffected, isLoading, isSelected } = data + const theme = useTheme() + + const borderColor = isSelected + ? theme.palette.primary.main + : isAffected + ? theme.palette.primary.main + : theme.palette.divider + + return ( + <> + + + + + {cardIndex} + + + + {label !== '' ? label : '…'} + + + + + ) + } +) +AiCardNode.displayName = 'AiCardNode' + +interface AiEditorFlowMapProps { + journey: JourneySimple + selectedCardId: string | null + aiState: AiState + onCardSelect: (cardId: string) => void + sx?: SxProps +} + +function AiEditorFlowMapInner({ + journey, + selectedCardId, + aiState, + onCardSelect, + sx +}: AiEditorFlowMapProps): ReactElement { + const nodeTypes = useMemo(() => ({ aiCard: AiCardNode }), []) + + const { nodes, edges } = useMemo(() => { + const nodes: Node[] = journey.cards.map((card, index) => ({ + id: card.id, + position: { x: card.x, y: card.y }, + type: 'aiCard', + data: { + cardIndex: index + 1, + label: card.heading ?? card.text ?? '', + isAffected: aiState.affectedCardIds.includes(card.id), + isLoading: aiState.status === 'loading', + isSelected: card.id === selectedCardId + } + })) + + const edges: Edge[] = [] + for (const card of journey.cards) { + if (card.defaultNextCard != null) { + edges.push({ + id: `${card.id}->default->${card.defaultNextCard}`, + source: card.id, + target: card.defaultNextCard, + style: { strokeWidth: 1.5 } + }) + } + if (card.button?.nextCard != null) { + edges.push({ + id: `${card.id}->btn->${card.button.nextCard}`, + source: card.id, + target: card.button.nextCard, + label: card.button.text, + style: { strokeWidth: 1.5 } + }) + } + if (card.poll != null) { + card.poll.forEach((opt, i) => { + if (opt.nextCard != null) { + edges.push({ + id: `${card.id}->poll${i}->${opt.nextCard}`, + source: card.id, + target: opt.nextCard, + label: opt.text, + style: { strokeWidth: 1.5 } + }) + } + }) + } + } + + return { nodes, edges } + }, [journey, selectedCardId, aiState]) + + function handleNodeClick(_: MouseEvent, node: Node): void { + onCardSelect(node.id) + } + + return ( + + + + + + ) +} + +export function AiEditorFlowMap(props: AiEditorFlowMapProps): ReactElement { + return ( + + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiEditorHeader.tsx b/apps/journeys-admin/src/components/AiEditor/AiEditorHeader.tsx new file mode 100644 index 00000000000..59afee4c699 --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiEditorHeader.tsx @@ -0,0 +1,87 @@ +import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded' +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' +import IconButton from '@mui/material/IconButton' +import Tooltip from '@mui/material/Tooltip' +import Typography from '@mui/material/Typography' +import NextLink from 'next/link' +import { useTranslation } from 'next-i18next' +import { ReactElement } from 'react' + +interface AiEditorHeaderProps { + journeyId: string + journeyTitle: string +} + +export function AiEditorHeader({ + journeyId, + journeyTitle +}: AiEditorHeaderProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + + return ( + + + + + + + + + + + {journeyTitle} + + + — {t('AI Editor')} + + + + + + + + ) +} diff --git a/apps/journeys-admin/src/components/AiEditor/AiEditorPreview.tsx b/apps/journeys-admin/src/components/AiEditor/AiEditorPreview.tsx new file mode 100644 index 00000000000..4cf7316266a --- /dev/null +++ b/apps/journeys-admin/src/components/AiEditor/AiEditorPreview.tsx @@ -0,0 +1,56 @@ +import Box from '@mui/material/Box' +import { SxProps } from '@mui/material/styles' +import { ReactElement, useState } from 'react' + +import { JourneySimple } from '@core/shared/ai/journeySimpleTypes' + +import { AiState } from './AiChat/AiChat' +import { AiEditorCardPreview } from './AiEditorCardPreview' +import { AiEditorFlowMap } from './AiEditorFlowMap' + +interface AiEditorPreviewProps { + journey: JourneySimple + aiState: AiState + onSelectedCardChange: (cardId: string | null) => void + sx?: SxProps +} + +export function AiEditorPreview({ + journey, + aiState, + onSelectedCardChange, + sx +}: AiEditorPreviewProps): ReactElement { + const [selectedCardId, setSelectedCardId] = useState(null) + + function handleCardSelect(cardId: string): void { + const next = selectedCardId === cardId ? null : cardId + setSelectedCardId(next) + onSelectedCardChange(next) + } + + return ( + + + + + ) +} diff --git a/apps/journeys-admin/src/components/Editor/Toolbar/Toolbar.tsx b/apps/journeys-admin/src/components/Editor/Toolbar/Toolbar.tsx index e4e62d525fe..d783d2e0422 100644 --- a/apps/journeys-admin/src/components/Editor/Toolbar/Toolbar.tsx +++ b/apps/journeys-admin/src/components/Editor/Toolbar/Toolbar.tsx @@ -1,4 +1,5 @@ import { gql, useApolloClient, useMutation } from '@apollo/client' +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted' import Box from '@mui/material/Box' import Button from '@mui/material/Button' @@ -345,6 +346,17 @@ export function Toolbar({ user }: ToolbarProps): ReactElement { alignItems="center" > + {journey != null && journey.template !== true && smUp && ( + + + + + + )} diff --git a/apps/journeys-admin/src/components/JourneyList/JourneyCard/JourneyCardMenu/DefaultMenu/DefaultMenu.tsx b/apps/journeys-admin/src/components/JourneyList/JourneyCard/JourneyCardMenu/DefaultMenu/DefaultMenu.tsx index 5140d7786ad..e5db5d49a43 100644 --- a/apps/journeys-admin/src/components/JourneyList/JourneyCard/JourneyCardMenu/DefaultMenu/DefaultMenu.tsx +++ b/apps/journeys-admin/src/components/JourneyList/JourneyCard/JourneyCardMenu/DefaultMenu/DefaultMenu.tsx @@ -1,4 +1,5 @@ import { ApolloQueryResult, gql, useQuery } from '@apollo/client' +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome' import Divider from '@mui/material/Divider' import NextLink from 'next/link' import { useTranslation } from 'next-i18next' @@ -211,6 +212,14 @@ export function DefaultMenu({ setHasOpenDialog?.(true) }} /> + {template !== true && ( + + } + /> + + )} {template !== true && (