@ -7,10 +7,11 @@ import { parseDynamicTheme } from '../parseDynamicTheme'
|
||||
import { saveStateToDatabase } from '../saveStateToDatabase'
|
||||
import { computeCurrentProgress } from '../computeCurrentProgress'
|
||||
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
|
||||
import { Message } from '@typebot.io/schemas'
|
||||
|
||||
type Props = {
|
||||
origin: string | undefined
|
||||
message?: string
|
||||
message?: Message
|
||||
sessionId: string
|
||||
textBubbleContentFormat: 'richText' | 'markdown'
|
||||
}
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
|
||||
import { Message } from '@typebot.io/schemas'
|
||||
import { computeCurrentProgress } from '../computeCurrentProgress'
|
||||
import { filterPotentiallySensitiveLogs } from '../logs/filterPotentiallySensitiveLogs'
|
||||
import { restartSession } from '../queries/restartSession'
|
||||
import { saveStateToDatabase } from '../saveStateToDatabase'
|
||||
import { startSession } from '../startSession'
|
||||
import { isNotEmpty } from '@typebot.io/lib'
|
||||
|
||||
type Props = {
|
||||
origin: string | undefined
|
||||
message?: string
|
||||
message?: Message
|
||||
isOnlyRegistering: boolean
|
||||
publicId: string
|
||||
isStreamEnabled: boolean
|
||||
@ -48,8 +48,8 @@ export const startChat = async ({
|
||||
prefilledVariables,
|
||||
resultId: startResultId,
|
||||
textBubbleContentFormat,
|
||||
message,
|
||||
},
|
||||
message,
|
||||
})
|
||||
|
||||
let corsOrigin
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { StartFrom, StartTypebot } from '@typebot.io/schemas'
|
||||
import { Message, StartFrom, StartTypebot } from '@typebot.io/schemas'
|
||||
import { restartSession } from '../queries/restartSession'
|
||||
import { saveStateToDatabase } from '../saveStateToDatabase'
|
||||
import { startSession } from '../startSession'
|
||||
@ -6,7 +6,7 @@ import { computeCurrentProgress } from '../computeCurrentProgress'
|
||||
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
|
||||
|
||||
type Props = {
|
||||
message?: string
|
||||
message?: Message
|
||||
isOnlyRegistering: boolean
|
||||
isStreamEnabled: boolean
|
||||
startFrom?: StartFrom
|
||||
@ -53,8 +53,8 @@ export const startChatPreview = async ({
|
||||
prefilledVariables,
|
||||
sessionId,
|
||||
textBubbleContentFormat,
|
||||
message,
|
||||
},
|
||||
message,
|
||||
})
|
||||
|
||||
const session = isOnlyRegistering
|
||||
|
@ -23,6 +23,7 @@ import {
|
||||
} from '@typebot.io/schemas/features/blocks/logic/setVariable/constants'
|
||||
import { createCodeRunner } from '@typebot.io/variables/codeRunners'
|
||||
import { stringifyError } from '@typebot.io/lib/stringifyError'
|
||||
import { AnswerV2 } from '@typebot.io/prisma'
|
||||
|
||||
export const executeSetVariable = async (
|
||||
state: SessionState,
|
||||
@ -246,7 +247,7 @@ const toISOWithTz = (date: Date, timeZone: string) => {
|
||||
}
|
||||
|
||||
type ParsedTranscriptProps = {
|
||||
answers: Pick<Answer, 'blockId' | 'content'>[]
|
||||
answers: Pick<Answer, 'blockId' | 'content' | 'attachedFileUrls'>[]
|
||||
setVariableHistory: Pick<
|
||||
SetVariableHistoryItem,
|
||||
'blockId' | 'variableId' | 'value'
|
||||
@ -273,6 +274,10 @@ const parsePreviewTranscriptProps = async (
|
||||
}
|
||||
}
|
||||
|
||||
type UnifiedAnswersFromDB = (ParsedTranscriptProps['answers'][number] & {
|
||||
createdAt: Date
|
||||
})[]
|
||||
|
||||
const parseResultTranscriptProps = async (
|
||||
state: SessionState
|
||||
): Promise<ParsedTranscriptProps | undefined> => {
|
||||
@ -299,6 +304,7 @@ const parseResultTranscriptProps = async (
|
||||
blockId: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
attachedFileUrls: true,
|
||||
},
|
||||
},
|
||||
setVariableHistory: {
|
||||
@ -313,8 +319,8 @@ const parseResultTranscriptProps = async (
|
||||
})
|
||||
if (!result) return
|
||||
return {
|
||||
answers: result.answersV2
|
||||
.concat(result.answers)
|
||||
answers: (result.answersV2 as UnifiedAnswersFromDB)
|
||||
.concat(result.answers as UnifiedAnswersFromDB)
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()),
|
||||
setVariableHistory: (
|
||||
result.setVariableHistory as SetVariableHistoryItem[]
|
||||
|
@ -4,11 +4,12 @@ import {
|
||||
ContinueChatResponse,
|
||||
Group,
|
||||
InputBlock,
|
||||
Message,
|
||||
SessionState,
|
||||
SetVariableHistoryItem,
|
||||
Variable,
|
||||
} from '@typebot.io/schemas'
|
||||
import { byId } from '@typebot.io/lib'
|
||||
import { byId, isDefined } from '@typebot.io/lib'
|
||||
import { isInputBlock } from '@typebot.io/schemas/helpers'
|
||||
import { executeGroup, parseInput } from './executeGroup'
|
||||
import { getNextGroup } from './getNextGroup'
|
||||
@ -42,10 +43,9 @@ import { ForgedBlock } from '@typebot.io/forge-repository/types'
|
||||
import { forgedBlocks } from '@typebot.io/forge-repository/definitions'
|
||||
import { resumeChatCompletion } from './blocks/integrations/legacy/openai/resumeChatCompletion'
|
||||
import { env } from '@typebot.io/env'
|
||||
import { downloadMedia } from './whatsapp/downloadMedia'
|
||||
import { uploadFileToBucket } from '@typebot.io/lib/s3/uploadFileToBucket'
|
||||
import { isURL } from '@typebot.io/lib/validators/isURL'
|
||||
import { isForgedBlockType } from '@typebot.io/schemas/features/blocks/forged/helpers'
|
||||
import { resetSessionState } from './resetSessionState'
|
||||
|
||||
type Params = {
|
||||
version: 1 | 2
|
||||
@ -69,7 +69,11 @@ export const continueBotFlow = async (
|
||||
const setVariableHistory: SetVariableHistoryItem[] = []
|
||||
|
||||
if (!newSessionState.currentBlockId)
|
||||
return startBotFlow({ state, version, textBubbleContentFormat })
|
||||
return startBotFlow({
|
||||
state: resetSessionState(newSessionState),
|
||||
version,
|
||||
textBubbleContentFormat,
|
||||
})
|
||||
|
||||
const { block, group, blockIndex } = getBlockById(
|
||||
newSessionState.currentBlockId,
|
||||
@ -161,7 +165,7 @@ export const continueBotFlow = async (
|
||||
newVariables: [
|
||||
{
|
||||
...variableToUpdate,
|
||||
value: safeJsonParse(reply as string),
|
||||
value: reply?.text ? safeJsonParse(reply?.text) : undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
@ -187,7 +191,14 @@ export const continueBotFlow = async (
|
||||
|
||||
formattedReply =
|
||||
'reply' in parsedReplyResult ? parsedReplyResult.reply : undefined
|
||||
newSessionState = await processAndSaveAnswer(state, block)(formattedReply)
|
||||
newSessionState = await processAndSaveAnswer(
|
||||
state,
|
||||
block
|
||||
)(
|
||||
isDefined(formattedReply)
|
||||
? { ...reply, type: 'text', text: formattedReply }
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
const groupHasMoreBlocks = blockIndex < group.blocks.length - 1
|
||||
@ -267,37 +278,92 @@ export const continueBotFlow = async (
|
||||
|
||||
const processAndSaveAnswer =
|
||||
(state: SessionState, block: InputBlock) =>
|
||||
async (reply: string | undefined): Promise<SessionState> => {
|
||||
async (reply: Message | undefined): Promise<SessionState> => {
|
||||
if (!reply) return state
|
||||
let newState = await saveAnswerInDb(state, block)(reply)
|
||||
return newState
|
||||
return saveAnswerInDb(state, block)(reply)
|
||||
}
|
||||
|
||||
const saveVariableValueIfAny =
|
||||
const saveVariablesValueIfAny =
|
||||
(state: SessionState, block: InputBlock) =>
|
||||
(reply: string): SessionState => {
|
||||
(reply: Message): SessionState => {
|
||||
if (!block.options?.variableId) return state
|
||||
const foundVariable = state.typebotsQueue[0].typebot.variables.find(
|
||||
(variable) => variable.id === block.options?.variableId
|
||||
)
|
||||
if (!foundVariable) return state
|
||||
|
||||
const { updatedState } = updateVariablesInSession({
|
||||
newVariables: [
|
||||
{
|
||||
...foundVariable,
|
||||
value: Array.isArray(foundVariable.value)
|
||||
? foundVariable.value.concat(reply)
|
||||
: reply,
|
||||
},
|
||||
],
|
||||
currentBlockId: undefined,
|
||||
state,
|
||||
})
|
||||
|
||||
return updatedState
|
||||
const newSessionState = saveAttachmentsVarIfAny({ block, reply, state })
|
||||
return saveInputVarIfAny({ block, reply, state: newSessionState })
|
||||
}
|
||||
|
||||
const saveAttachmentsVarIfAny = ({
|
||||
block,
|
||||
reply,
|
||||
state,
|
||||
}: {
|
||||
block: InputBlock
|
||||
reply: Message
|
||||
state: SessionState
|
||||
}): SessionState => {
|
||||
if (
|
||||
block.type !== InputBlockType.TEXT ||
|
||||
!block.options?.attachments?.isEnabled ||
|
||||
!block.options?.attachments?.saveVariableId ||
|
||||
!reply.attachedFileUrls ||
|
||||
reply.attachedFileUrls.length === 0
|
||||
)
|
||||
return state
|
||||
|
||||
const variable = state.typebotsQueue[0].typebot.variables.find(
|
||||
(variable) => variable.id === block.options?.attachments?.saveVariableId
|
||||
)
|
||||
|
||||
if (!variable) return state
|
||||
|
||||
const { updatedState } = updateVariablesInSession({
|
||||
newVariables: [
|
||||
{
|
||||
id: variable.id,
|
||||
name: variable.name,
|
||||
value: Array.isArray(variable.value)
|
||||
? variable.value.concat(reply.attachedFileUrls)
|
||||
: reply.attachedFileUrls.length === 1
|
||||
? reply.attachedFileUrls[0]
|
||||
: reply.attachedFileUrls,
|
||||
},
|
||||
],
|
||||
currentBlockId: undefined,
|
||||
state,
|
||||
})
|
||||
return updatedState
|
||||
}
|
||||
|
||||
const saveInputVarIfAny = ({
|
||||
block,
|
||||
reply,
|
||||
state,
|
||||
}: {
|
||||
block: InputBlock
|
||||
reply: Message
|
||||
state: SessionState
|
||||
}): SessionState => {
|
||||
const foundVariable = state.typebotsQueue[0].typebot.variables.find(
|
||||
(variable) => variable.id === block.options?.variableId
|
||||
)
|
||||
if (!foundVariable) return state
|
||||
|
||||
const { updatedState } = updateVariablesInSession({
|
||||
newVariables: [
|
||||
{
|
||||
...foundVariable,
|
||||
value:
|
||||
Array.isArray(foundVariable.value) && reply.text
|
||||
? foundVariable.value.concat(reply.text)
|
||||
: reply.text,
|
||||
},
|
||||
],
|
||||
currentBlockId: undefined,
|
||||
state,
|
||||
})
|
||||
|
||||
return updatedState
|
||||
}
|
||||
|
||||
const parseRetryMessage =
|
||||
(state: SessionState) =>
|
||||
async (
|
||||
@ -346,26 +412,27 @@ const parseDefaultRetryMessage = (block: InputBlock): string => {
|
||||
|
||||
const saveAnswerInDb =
|
||||
(state: SessionState, block: InputBlock) =>
|
||||
async (reply: string): Promise<SessionState> => {
|
||||
async (reply: Message): Promise<SessionState> => {
|
||||
let newSessionState = state
|
||||
await saveAnswer({
|
||||
answer: {
|
||||
blockId: block.id,
|
||||
content: reply,
|
||||
content: reply.text,
|
||||
attachedFileUrls: reply.attachedFileUrls,
|
||||
},
|
||||
reply,
|
||||
state,
|
||||
})
|
||||
|
||||
newSessionState = {
|
||||
...saveVariableValueIfAny(newSessionState, block)(reply),
|
||||
...saveVariablesValueIfAny(newSessionState, block)(reply),
|
||||
previewMetadata: state.typebotsQueue[0].resultId
|
||||
? newSessionState.previewMetadata
|
||||
: {
|
||||
...newSessionState.previewMetadata,
|
||||
answers: (newSessionState.previewMetadata?.answers ?? []).concat({
|
||||
blockId: block.id,
|
||||
content: reply,
|
||||
content: reply.text,
|
||||
attachedFileUrls: reply.attachedFileUrls,
|
||||
}),
|
||||
},
|
||||
}
|
||||
@ -378,7 +445,10 @@ const saveAnswerInDb =
|
||||
|
||||
return setNewAnswerInState(newSessionState)({
|
||||
key: key ?? block.id,
|
||||
value: reply,
|
||||
value:
|
||||
(reply.attachedFileUrls ?? []).length > 0
|
||||
? `${reply.attachedFileUrls!.join(', ')}\n\n${reply.text}`
|
||||
: reply.text,
|
||||
})
|
||||
}
|
||||
|
||||
@ -465,41 +535,17 @@ const getOutgoingEdgeId =
|
||||
const parseReply =
|
||||
(state: SessionState) =>
|
||||
async (reply: Reply, block: InputBlock): Promise<ParsedReply> => {
|
||||
if (reply && typeof reply !== 'string') {
|
||||
if (block.type !== InputBlockType.FILE) return { status: 'fail' }
|
||||
if (block.options?.visibility !== 'Public') {
|
||||
return {
|
||||
status: 'success',
|
||||
reply:
|
||||
env.NEXTAUTH_URL +
|
||||
`/api/typebots/${state.typebotsQueue[0].typebot.id}/whatsapp/media/${reply.mediaId}`,
|
||||
}
|
||||
}
|
||||
const { file, mimeType } = await downloadMedia({
|
||||
mediaId: reply.mediaId,
|
||||
systemUserAccessToken: reply.accessToken,
|
||||
})
|
||||
const url = await uploadFileToBucket({
|
||||
file,
|
||||
key: `public/workspaces/${reply.workspaceId}/typebots/${state.typebotsQueue[0].typebot.id}/results/${state.typebotsQueue[0].resultId}/${reply.mediaId}`,
|
||||
mimeType,
|
||||
})
|
||||
return {
|
||||
status: 'success',
|
||||
reply: url,
|
||||
}
|
||||
}
|
||||
switch (block.type) {
|
||||
case InputBlockType.EMAIL: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
const formattedEmail = formatEmail(reply)
|
||||
const formattedEmail = formatEmail(reply.text)
|
||||
if (!formattedEmail) return { status: 'fail' }
|
||||
return { status: 'success', reply: formattedEmail }
|
||||
}
|
||||
case InputBlockType.PHONE: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
const formattedPhone = formatPhoneNumber(
|
||||
reply,
|
||||
reply.text,
|
||||
block.options?.defaultCountryCode
|
||||
)
|
||||
if (!formattedPhone) return { status: 'fail' }
|
||||
@ -507,58 +553,60 @@ const parseReply =
|
||||
}
|
||||
case InputBlockType.URL: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
const isValid = isURL(reply, { require_protocol: false })
|
||||
const isValid = isURL(reply.text, { require_protocol: false })
|
||||
if (!isValid) return { status: 'fail' }
|
||||
return { status: 'success', reply: reply }
|
||||
return { status: 'success', reply: reply.text }
|
||||
}
|
||||
case InputBlockType.CHOICE: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
return parseButtonsReply(state)(reply, block)
|
||||
return parseButtonsReply(state)(reply.text, block)
|
||||
}
|
||||
case InputBlockType.NUMBER: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
const isValid = validateNumber(reply, {
|
||||
const isValid = validateNumber(reply.text, {
|
||||
options: block.options,
|
||||
variables: state.typebotsQueue[0].typebot.variables,
|
||||
})
|
||||
if (!isValid) return { status: 'fail' }
|
||||
return { status: 'success', reply: parseNumber(reply) }
|
||||
return { status: 'success', reply: parseNumber(reply.text) }
|
||||
}
|
||||
case InputBlockType.DATE: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
return parseDateReply(reply, block)
|
||||
return parseDateReply(reply.text, block)
|
||||
}
|
||||
case InputBlockType.FILE: {
|
||||
if (!reply)
|
||||
return block.options?.isRequired ?? defaultFileInputOptions.isRequired
|
||||
? { status: 'fail' }
|
||||
: { status: 'skip' }
|
||||
const urls = reply.split(', ')
|
||||
const urls = reply.text.split(', ')
|
||||
const status = urls.some((url) =>
|
||||
isURL(url, { require_tld: env.S3_ENDPOINT !== 'localhost' })
|
||||
)
|
||||
? 'success'
|
||||
: 'fail'
|
||||
return { status, reply: reply }
|
||||
if (!block.options?.isMultipleAllowed && urls.length > 1)
|
||||
return { status, reply: reply.text.split(',')[0] }
|
||||
return { status, reply: reply.text }
|
||||
}
|
||||
case InputBlockType.PAYMENT: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
if (reply === 'fail') return { status: 'fail' }
|
||||
return { status: 'success', reply: reply }
|
||||
if (reply.text === 'fail') return { status: 'fail' }
|
||||
return { status: 'success', reply: reply.text }
|
||||
}
|
||||
case InputBlockType.RATING: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
const isValid = validateRatingReply(reply, block)
|
||||
const isValid = validateRatingReply(reply.text, block)
|
||||
if (!isValid) return { status: 'fail' }
|
||||
return { status: 'success', reply: reply }
|
||||
return { status: 'success', reply: reply.text }
|
||||
}
|
||||
case InputBlockType.PICTURE_CHOICE: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
return parsePictureChoicesReply(state)(reply, block)
|
||||
return parsePictureChoicesReply(state)(reply.text, block)
|
||||
}
|
||||
case InputBlockType.TEXT: {
|
||||
if (!reply) return { status: 'fail' }
|
||||
return { status: 'success', reply: reply }
|
||||
return { status: 'success', reply: reply.text }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,6 @@ import { SessionState } from '@typebot.io/schemas'
|
||||
|
||||
type Props = {
|
||||
answer: Omit<Prisma.AnswerV2CreateManyInput, 'resultId'>
|
||||
reply: string
|
||||
state: SessionState
|
||||
}
|
||||
export const saveAnswer = async ({ answer, state }: Props) => {
|
||||
|
20
packages/bot-engine/resetSessionState.ts
Normal file
20
packages/bot-engine/resetSessionState.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { SessionState } from '@typebot.io/schemas/features/chat/sessionState'
|
||||
|
||||
export const resetSessionState = (state: SessionState): SessionState => ({
|
||||
...state,
|
||||
currentSetVariableHistoryIndex: undefined,
|
||||
currentVisitedEdgeIndex: undefined,
|
||||
previewMetadata: undefined,
|
||||
progressMetadata: undefined,
|
||||
typebotsQueue: state.typebotsQueue.map((queueItem) => ({
|
||||
...queueItem,
|
||||
answers: [],
|
||||
typebot: {
|
||||
...queueItem.typebot,
|
||||
variables: queueItem.typebot.variables.map((variable) => ({
|
||||
...variable,
|
||||
value: undefined,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
})
|
@ -61,14 +61,12 @@ type StartParams =
|
||||
|
||||
type Props = {
|
||||
version: 1 | 2
|
||||
message: Reply
|
||||
startParams: StartParams
|
||||
initialSessionState?: Pick<SessionState, 'whatsApp' | 'expiryTimeout'>
|
||||
}
|
||||
|
||||
export const startSession = async ({
|
||||
version,
|
||||
message,
|
||||
startParams,
|
||||
initialSessionState,
|
||||
}: Props): Promise<
|
||||
@ -188,7 +186,7 @@ export const startSession = async ({
|
||||
})
|
||||
|
||||
// If params has message and first block is an input block, we can directly continue the bot flow
|
||||
if (message) {
|
||||
if (startParams.message) {
|
||||
const firstEdgeId = getFirstEdgeId({
|
||||
typebot: chatReply.newSessionState.typebotsQueue[0].typebot,
|
||||
startEventId:
|
||||
@ -213,7 +211,7 @@ export const startSession = async ({
|
||||
resultId,
|
||||
typebot: newSessionState.typebotsQueue[0].typebot,
|
||||
})
|
||||
chatReply = await continueBotFlow(message, {
|
||||
chatReply = await continueBotFlow(startParams.message, {
|
||||
version,
|
||||
state: {
|
||||
...newSessionState,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {
|
||||
ContinueChatResponse,
|
||||
CustomEmbedBubble,
|
||||
Message,
|
||||
SessionState,
|
||||
SetVariableHistoryItem,
|
||||
} from '@typebot.io/schemas'
|
||||
@ -21,14 +22,7 @@ export type ExecuteIntegrationResponse = {
|
||||
newSetVariableHistory?: SetVariableHistoryItem[]
|
||||
} & Pick<ContinueChatResponse, 'clientSideActions' | 'logs'>
|
||||
|
||||
type WhatsAppMediaMessage = {
|
||||
type: 'whatsapp media'
|
||||
mediaId: string
|
||||
workspaceId?: string
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
export type Reply = string | WhatsAppMediaMessage | undefined
|
||||
export type Reply = Message | undefined
|
||||
|
||||
export type ParsedReply =
|
||||
| { status: 'success'; reply: string }
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { SessionState } from '@typebot.io/schemas'
|
||||
import { Block, SessionState } from '@typebot.io/schemas'
|
||||
import {
|
||||
WhatsAppCredentials,
|
||||
WhatsAppIncomingMessage,
|
||||
@ -15,6 +15,13 @@ import { isDefined } from '@typebot.io/lib/utils'
|
||||
import { Reply } from '../types'
|
||||
import { setIsReplyingInChatSession } from '../queries/setIsReplyingInChatSession'
|
||||
import { removeIsReplyingInChatSession } from '../queries/removeIsReplyingInChatSession'
|
||||
import redis from '@typebot.io/lib/redis'
|
||||
import { downloadMedia } from './downloadMedia'
|
||||
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
|
||||
import { uploadFileToBucket } from '@typebot.io/lib/s3/uploadFileToBucket'
|
||||
import { getBlockById } from '@typebot.io/schemas/helpers'
|
||||
|
||||
const incomingMessageDebounce = 3000
|
||||
|
||||
type Props = {
|
||||
receivedMessage: WhatsAppIncomingMessage
|
||||
@ -61,33 +68,59 @@ export const resumeWhatsAppFlow = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const reply = await getIncomingMessageContent({
|
||||
message: receivedMessage,
|
||||
workspaceId,
|
||||
accessToken: credentials?.systemUserAccessToken,
|
||||
})
|
||||
|
||||
const session = await getSession(sessionId)
|
||||
|
||||
const { incomingMessages, isReplyingWasSet } =
|
||||
await aggregateParallelMediaMessagesIfRedisEnabled({
|
||||
receivedMessage,
|
||||
existingSessionId: session?.id,
|
||||
newSessionId: sessionId,
|
||||
})
|
||||
|
||||
if (incomingMessages.length === 0) {
|
||||
if (isReplyingWasSet) await removeIsReplyingInChatSession(sessionId)
|
||||
|
||||
return {
|
||||
message: 'Message received',
|
||||
}
|
||||
}
|
||||
|
||||
const isSessionExpired =
|
||||
session &&
|
||||
isDefined(session.state.expiryTimeout) &&
|
||||
session?.updatedAt.getTime() + session.state.expiryTimeout < Date.now()
|
||||
|
||||
if (session?.isReplying) {
|
||||
if (!isSessionExpired) {
|
||||
console.log('Is currently replying, skipping...')
|
||||
return {
|
||||
message: 'Message received',
|
||||
if (!isReplyingWasSet) {
|
||||
if (session?.isReplying) {
|
||||
if (!isSessionExpired) {
|
||||
console.log('Is currently replying, skipping...')
|
||||
return {
|
||||
message: 'Message received',
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await setIsReplyingInChatSession({
|
||||
existingSessionId: session?.id,
|
||||
newSessionId: sessionId,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await setIsReplyingInChatSession({
|
||||
existingSessionId: session?.id,
|
||||
newSessionId: sessionId,
|
||||
})
|
||||
}
|
||||
|
||||
const currentTypebot = session?.state.typebotsQueue[0].typebot
|
||||
const { block } =
|
||||
(currentTypebot && session?.state.currentBlockId
|
||||
? getBlockById(session.state.currentBlockId, currentTypebot.groups)
|
||||
: undefined) ?? {}
|
||||
|
||||
const reply = await getIncomingMessageContent({
|
||||
messages: incomingMessages,
|
||||
workspaceId,
|
||||
accessToken: credentials?.systemUserAccessToken,
|
||||
typebotId: currentTypebot?.id,
|
||||
resultId: session?.state.typebotsQueue[0].resultId,
|
||||
block,
|
||||
})
|
||||
|
||||
const resumeResponse =
|
||||
session && !isSessionExpired
|
||||
? await continueBotFlow(reply, {
|
||||
@ -155,35 +188,107 @@ export const resumeWhatsAppFlow = async ({
|
||||
}
|
||||
|
||||
const getIncomingMessageContent = async ({
|
||||
message,
|
||||
messages,
|
||||
workspaceId,
|
||||
accessToken,
|
||||
typebotId,
|
||||
resultId,
|
||||
block,
|
||||
}: {
|
||||
message: WhatsAppIncomingMessage
|
||||
messages: WhatsAppIncomingMessage[]
|
||||
workspaceId?: string
|
||||
accessToken: string
|
||||
typebotId?: string
|
||||
resultId?: string
|
||||
block?: Block
|
||||
}): Promise<Reply> => {
|
||||
switch (message.type) {
|
||||
case 'text':
|
||||
return message.text.body
|
||||
case 'button':
|
||||
return message.button.text
|
||||
case 'interactive': {
|
||||
return message.interactive.button_reply.id
|
||||
let text: string = ''
|
||||
const attachedFileUrls: string[] = []
|
||||
for (const message of messages) {
|
||||
switch (message.type) {
|
||||
case 'text': {
|
||||
if (text !== '') text += `\n\n${message.text.body}`
|
||||
else text = message.text.body
|
||||
break
|
||||
}
|
||||
case 'button': {
|
||||
if (text !== '') text += `\n\n${message.button.text}`
|
||||
else text = message.button.text
|
||||
break
|
||||
}
|
||||
case 'interactive': {
|
||||
if (text !== '') text += `\n\n${message.interactive.button_reply.id}`
|
||||
else text = message.interactive.button_reply.id
|
||||
break
|
||||
}
|
||||
case 'document':
|
||||
case 'audio':
|
||||
case 'video':
|
||||
case 'image': {
|
||||
let mediaId: string | undefined
|
||||
if (message.type === 'video') mediaId = message.video.id
|
||||
if (message.type === 'image') mediaId = message.image.id
|
||||
if (message.type === 'audio') mediaId = message.audio.id
|
||||
if (message.type === 'document') mediaId = message.document.id
|
||||
if (!mediaId) return
|
||||
const fileVisibility =
|
||||
block?.type === InputBlockType.FILE
|
||||
? block.options?.visibility
|
||||
: block?.type === InputBlockType.TEXT
|
||||
? block.options?.attachments?.visibility
|
||||
: undefined
|
||||
let fileUrl
|
||||
if (fileVisibility !== 'Public') {
|
||||
fileUrl =
|
||||
env.NEXTAUTH_URL +
|
||||
`/api/typebots/${typebotId}/whatsapp/media/${
|
||||
workspaceId ? `` : 'preview/'
|
||||
}${mediaId}`
|
||||
} else {
|
||||
const { file, mimeType } = await downloadMedia({
|
||||
mediaId,
|
||||
systemUserAccessToken: accessToken,
|
||||
})
|
||||
const url = await uploadFileToBucket({
|
||||
file,
|
||||
key:
|
||||
resultId && workspaceId && typebotId
|
||||
? `public/workspaces/${workspaceId}/typebots/${typebotId}/results/${resultId}/${mediaId}`
|
||||
: `tmp/whatsapp/media/${mediaId}`,
|
||||
mimeType,
|
||||
})
|
||||
fileUrl = url
|
||||
}
|
||||
if (block?.type === InputBlockType.FILE) {
|
||||
if (text !== '') text += `, ${fileUrl}`
|
||||
else text = fileUrl
|
||||
} else if (block?.type === InputBlockType.TEXT) {
|
||||
let caption: string | undefined
|
||||
if (message.type === 'document' && message.document.caption) {
|
||||
if (!/^[\w,\s-]+\.[A-Za-z]{3}$/.test(message.document.caption))
|
||||
caption = message.document.caption
|
||||
} else if (message.type === 'image' && message.image.caption)
|
||||
caption = message.image.caption
|
||||
else if (message.type === 'video' && message.video.caption)
|
||||
caption = message.video.caption
|
||||
if (caption) text = text === '' ? caption : `${text}\n\n${caption}`
|
||||
attachedFileUrls.push(fileUrl)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'location': {
|
||||
const location = `${message.location.latitude}, ${message.location.longitude}`
|
||||
if (text !== '') text += `\n\n${location}`
|
||||
else text = location
|
||||
break
|
||||
}
|
||||
}
|
||||
case 'document':
|
||||
case 'audio':
|
||||
case 'video':
|
||||
case 'image':
|
||||
let mediaId: string | undefined
|
||||
if (message.type === 'video') mediaId = message.video.id
|
||||
if (message.type === 'image') mediaId = message.image.id
|
||||
if (message.type === 'audio') mediaId = message.audio.id
|
||||
if (message.type === 'document') mediaId = message.document.id
|
||||
if (!mediaId) return
|
||||
return { type: 'whatsapp media', mediaId, workspaceId, accessToken }
|
||||
case 'location':
|
||||
return `${message.location.latitude}, ${message.location.longitude}`
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'text',
|
||||
text,
|
||||
attachedFileUrls,
|
||||
}
|
||||
}
|
||||
|
||||
@ -227,3 +332,57 @@ const getCredentials = async ({
|
||||
phoneNumberId: data.phoneNumberId,
|
||||
}
|
||||
}
|
||||
|
||||
const aggregateParallelMediaMessagesIfRedisEnabled = async ({
|
||||
receivedMessage,
|
||||
existingSessionId,
|
||||
newSessionId,
|
||||
}: {
|
||||
receivedMessage: WhatsAppIncomingMessage
|
||||
existingSessionId?: string
|
||||
newSessionId: string
|
||||
}): Promise<{
|
||||
isReplyingWasSet: boolean
|
||||
incomingMessages: WhatsAppIncomingMessage[]
|
||||
}> => {
|
||||
if (redis && ['document', 'video', 'image'].includes(receivedMessage.type)) {
|
||||
const redisKey = `wasession:${newSessionId}`
|
||||
try {
|
||||
const len = await redis.rpush(redisKey, JSON.stringify(receivedMessage))
|
||||
|
||||
if (len === 1) {
|
||||
await setIsReplyingInChatSession({
|
||||
existingSessionId,
|
||||
newSessionId,
|
||||
})
|
||||
}
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, incomingMessageDebounce)
|
||||
)
|
||||
|
||||
const newMessagesResponse = await redis.lrange(redisKey, 0, -1)
|
||||
|
||||
if (!newMessagesResponse || newMessagesResponse.length > len) {
|
||||
// Current message was aggregated with other messages another webhook handler. Skipping...
|
||||
return { isReplyingWasSet: true, incomingMessages: [] }
|
||||
}
|
||||
|
||||
redis.del(redisKey).then()
|
||||
|
||||
return {
|
||||
isReplyingWasSet: true,
|
||||
incomingMessages: newMessagesResponse.map((msgStr) =>
|
||||
JSON.parse(msgStr)
|
||||
),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to process webhook event:', error, receivedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isReplyingWasSet: false,
|
||||
incomingMessages: [receivedMessage],
|
||||
}
|
||||
}
|
||||
|
@ -58,11 +58,16 @@ export const sendChatReplyToWhatsApp = async ({
|
||||
const result = await executeClientSideAction({ to, credentials })(action)
|
||||
if (!result) continue
|
||||
const { input, newSessionState, messages, clientSideActions } =
|
||||
await continueBotFlow(result.replyToSend, {
|
||||
version: 2,
|
||||
state,
|
||||
textBubbleContentFormat: 'richText',
|
||||
})
|
||||
await continueBotFlow(
|
||||
result.replyToSend
|
||||
? { type: 'text', text: result.replyToSend }
|
||||
: undefined,
|
||||
{
|
||||
version: 2,
|
||||
state,
|
||||
textBubbleContentFormat: 'richText',
|
||||
}
|
||||
)
|
||||
|
||||
return sendChatReplyToWhatsApp({
|
||||
to,
|
||||
@ -128,11 +133,16 @@ export const sendChatReplyToWhatsApp = async ({
|
||||
)
|
||||
if (!result) continue
|
||||
const { input, newSessionState, messages, clientSideActions } =
|
||||
await continueBotFlow(result.replyToSend, {
|
||||
version: 2,
|
||||
state,
|
||||
textBubbleContentFormat: 'richText',
|
||||
})
|
||||
await continueBotFlow(
|
||||
result.replyToSend
|
||||
? { type: 'text', text: result.replyToSend }
|
||||
: undefined,
|
||||
{
|
||||
version: 2,
|
||||
state,
|
||||
textBubbleContentFormat: 'richText',
|
||||
}
|
||||
)
|
||||
|
||||
return sendChatReplyToWhatsApp({
|
||||
to,
|
||||
|
@ -68,7 +68,7 @@ export const startWhatsAppSession = async ({
|
||||
(publicTypebot.settings.whatsApp?.startCondition?.comparisons.length ??
|
||||
0) > 0 &&
|
||||
messageMatchStartCondition(
|
||||
incomingMessage ?? '',
|
||||
incomingMessage ?? { type: 'text', text: '' },
|
||||
publicTypebot.settings.whatsApp?.startCondition
|
||||
)
|
||||
)
|
||||
@ -90,13 +90,13 @@ export const startWhatsAppSession = async ({
|
||||
|
||||
return startSession({
|
||||
version: 2,
|
||||
message: incomingMessage,
|
||||
startParams: {
|
||||
type: 'live',
|
||||
publicId: publicTypebot.typebot.publicId as string,
|
||||
isOnlyRegistering: false,
|
||||
isStreamEnabled: false,
|
||||
textBubbleContentFormat: 'richText',
|
||||
message: incomingMessage,
|
||||
},
|
||||
initialSessionState: {
|
||||
whatsApp: {
|
||||
|
@ -16,7 +16,7 @@ npm install @typebot.io/js
|
||||
|
||||
```
|
||||
<script type="module">
|
||||
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.2/dist/web.js'
|
||||
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.3/dist/web.js'
|
||||
|
||||
Typebot.initStandard({
|
||||
typebot: 'my-typebot',
|
||||
@ -34,7 +34,7 @@ There, you can change the container dimensions. Here is a code example:
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.2/dist/web.js'
|
||||
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.3/dist/web.js'
|
||||
|
||||
Typebot.initStandard({
|
||||
typebot: 'my-typebot',
|
||||
@ -54,7 +54,7 @@ Here is an example:
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.2/dist/web.js'
|
||||
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.3/dist/web.js'
|
||||
|
||||
Typebot.initPopup({
|
||||
typebot: 'my-typebot',
|
||||
@ -96,7 +96,7 @@ Here is an example:
|
||||
|
||||
```html
|
||||
<script type="module">
|
||||
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.2/dist/web.js'
|
||||
import Typebot from 'https://cdn.jsdelivr.net/npm/@typebot.io/js@0.3/dist/web.js'
|
||||
|
||||
Typebot.initBubble({
|
||||
typebot: 'my-typebot',
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@typebot.io/js",
|
||||
"version": "0.2.92",
|
||||
"version": "0.3.0",
|
||||
"description": "Javascript library to display typebots on your website",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@ -13,6 +13,7 @@
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@ark-ui/solid": "3.3.0",
|
||||
"@stripe/stripe-js": "1.54.1",
|
||||
"@udecode/plate-common": "30.4.5",
|
||||
"dompurify": "3.0.6",
|
||||
|
@ -20,6 +20,7 @@ const indexConfig = {
|
||||
file: 'dist/index.js',
|
||||
format: 'es',
|
||||
},
|
||||
onwarn,
|
||||
plugins: [
|
||||
resolve({ extensions }),
|
||||
babel({
|
||||
@ -56,4 +57,15 @@ const configs = [
|
||||
},
|
||||
]
|
||||
|
||||
function onwarn(warning, warn) {
|
||||
if (
|
||||
warning.code === 'CIRCULAR_DEPENDENCY' &&
|
||||
warning.ids.some((id) => id.includes('@internationalized+date'))
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
warn(warning.message)
|
||||
}
|
||||
|
||||
export default configs
|
||||
|
@ -265,6 +265,10 @@ pre {
|
||||
backdrop-filter: blur(var(--typebot-guest-bubble-blur));
|
||||
}
|
||||
|
||||
.typebot-guest-bubble-image-attachment {
|
||||
border-radius: var(--typebot-guest-bubble-border-radius);
|
||||
}
|
||||
|
||||
.typebot-input {
|
||||
color: var(--typebot-input-color);
|
||||
background-color: rgba(
|
||||
@ -279,12 +283,17 @@ pre {
|
||||
border-radius: var(--typebot-input-border-radius);
|
||||
box-shadow: var(--typebot-input-box-shadow);
|
||||
backdrop-filter: blur(var(--typebot-input-blur));
|
||||
transition: filter 100ms ease;
|
||||
}
|
||||
|
||||
.typebot-input-error-message {
|
||||
color: var(--typebot-input-color);
|
||||
}
|
||||
|
||||
.typebot-input-form .typebot-button {
|
||||
box-shadow: var(--typebot-input-box-shadow);
|
||||
}
|
||||
|
||||
.typebot-button > .send-icon {
|
||||
fill: var(--typebot-button-color);
|
||||
}
|
||||
@ -446,3 +455,138 @@ select option {
|
||||
height: 100%;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeInFromTop {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOutFromTop {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInFromBottom {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeOutFromBottom {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
}
|
||||
|
||||
[data-scope='menu'][data-part='content'] {
|
||||
color: var(--typebot-input-color);
|
||||
background-color: rgba(
|
||||
var(--typebot-input-bg-rgb),
|
||||
var(--typebot-input-opacity)
|
||||
);
|
||||
border-width: var(--typebot-input-border-width);
|
||||
border-color: rgba(
|
||||
var(--typebot-input-border-rgb),
|
||||
var(--typebot-input-border-opacity)
|
||||
);
|
||||
border-radius: var(--typebot-input-border-radius);
|
||||
box-shadow: var(--typebot-input-box-shadow);
|
||||
backdrop-filter: blur(var(--typebot-input-blur));
|
||||
}
|
||||
|
||||
[data-scope='menu'][data-part='item'] {
|
||||
cursor: pointer;
|
||||
background-color: rgba(
|
||||
var(--typebot-input-bg-rgb),
|
||||
var(--typebot-input-opacity)
|
||||
);
|
||||
border-radius: var(--typebot-input-border-radius);
|
||||
}
|
||||
|
||||
[data-scope='menu'][data-part='content'][data-state='open'] {
|
||||
animation: fadeInFromTop 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
[data-scope='menu'][data-part='content'][data-state='closed'] {
|
||||
animation: fadeOutFromTop 50ms ease-out forwards;
|
||||
}
|
||||
|
||||
[data-scope='toast'][data-part='group'] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-scope='toast'][data-part='root'] {
|
||||
border-radius: var(--typebot-chat-container-border-radius);
|
||||
color: var(--typebot-input-color);
|
||||
background-color: rgba(
|
||||
var(--typebot-input-bg-rgb),
|
||||
var(--typebot-input-opacity)
|
||||
);
|
||||
box-shadow: var(--typebot-input-box-shadow);
|
||||
max-width: 60vw;
|
||||
@apply flex flex-col pl-4 py-4 pr-8 gap-1;
|
||||
}
|
||||
|
||||
[data-scope='toast'][data-part='title'] {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
[data-scope='toast'][data-part='description'] {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
[data-scope='toast'][data-part='root'][data-state='open'] {
|
||||
animation: fadeInFromBottom 150ms ease-out forwards;
|
||||
}
|
||||
|
||||
[data-scope='toast'][data-part='root'][data-state='closed'] {
|
||||
animation: fadeOutFromBottom 50ms ease-out forwards;
|
||||
}
|
||||
|
||||
[data-scope='progress'][data-part='root'] {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
[data-scope='progress'][data-part='circle'] {
|
||||
--size: 40px;
|
||||
--thickness: 4px;
|
||||
--radius: calc(40px / 2 - 4px / 2);
|
||||
--circomference: calc(2 * 3.14159 * calc(40px / 2 - 4px / 2));
|
||||
}
|
||||
|
||||
[data-scope='progress'][data-part='circle-range'] {
|
||||
stroke: white;
|
||||
--transition-prop: stroke-dasharray, stroke, stroke-dashoffset;
|
||||
transition-property: stroke-dasharray, stroke, stroke-dashoffset;
|
||||
--transition-duration: 0.2s;
|
||||
transition-duration: 0.2s;
|
||||
}
|
||||
|
||||
[data-scope='progress'][data-part='circle-track'] {
|
||||
stroke: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
@ -30,6 +30,9 @@ import {
|
||||
defaultProgressBarPosition,
|
||||
} from '@typebot.io/schemas/features/typebot/theme/constants'
|
||||
import { CorsError } from '@/utils/CorsError'
|
||||
import { Toaster, Toast } from '@ark-ui/solid'
|
||||
import { CloseIcon } from './icons/CloseIcon'
|
||||
import { toaster } from '@/utils/toaster'
|
||||
|
||||
export type BotProps = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@ -335,6 +338,17 @@ const BotContent = (props: BotContentProps) => {
|
||||
>
|
||||
<LiteBadge botContainer={botContainer} />
|
||||
</Show>
|
||||
<Toaster toaster={toaster}>
|
||||
{(toast) => (
|
||||
<Toast.Root>
|
||||
<Toast.Title>{toast().title}</Toast.Title>
|
||||
<Toast.Description>{toast().description}</Toast.Description>
|
||||
<Toast.CloseTrigger class="absolute right-2 top-2">
|
||||
<CloseIcon class="w-4 h-4" />
|
||||
</Toast.CloseTrigger>
|
||||
</Toast.Root>
|
||||
)}
|
||||
</Toaster>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -29,7 +29,6 @@ import {
|
||||
formattedMessages,
|
||||
setFormattedMessages,
|
||||
} from '@/utils/formattedMessagesSignal'
|
||||
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
|
||||
import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery'
|
||||
import { HTTPError } from 'ky'
|
||||
import { persist } from '@/utils/persist'
|
||||
@ -147,13 +146,6 @@ export const ConversationContainer = (props: Props) => {
|
||||
const currentInputBlock = [...chatChunks()].pop()?.input
|
||||
if (currentInputBlock?.id && props.onAnswer && message)
|
||||
props.onAnswer({ message, blockId: currentInputBlock.id })
|
||||
if (currentInputBlock?.type === InputBlockType.FILE)
|
||||
props.onNewLogs?.([
|
||||
{
|
||||
description: 'Files are not uploaded in preview mode',
|
||||
status: 'info',
|
||||
},
|
||||
])
|
||||
const longRequest = setTimeout(() => {
|
||||
setIsSending(true)
|
||||
}, 1000)
|
||||
|
@ -15,7 +15,7 @@ import type {
|
||||
DateInputBlock,
|
||||
} from '@typebot.io/schemas'
|
||||
import { GuestBubble } from './bubbles/GuestBubble'
|
||||
import { BotContext, InputSubmitContent } from '@/types'
|
||||
import { Answer, BotContext, InputSubmitContent } from '@/types'
|
||||
import { TextInput } from '@/features/blocks/inputs/textInput'
|
||||
import { NumberInput } from '@/features/blocks/inputs/number'
|
||||
import { EmailInput } from '@/features/blocks/inputs/email'
|
||||
@ -48,24 +48,33 @@ type Props = {
|
||||
isInputPrefillEnabled: boolean
|
||||
hasError: boolean
|
||||
onTransitionEnd: () => void
|
||||
onSubmit: (answer: string) => void
|
||||
onSubmit: (answer: string, attachments?: Answer['attachments']) => void
|
||||
onSkip: () => void
|
||||
}
|
||||
|
||||
export const InputChatBlock = (props: Props) => {
|
||||
const [answer, setAnswer] = persist(createSignal<string>(), {
|
||||
const [answer, setAnswer] = persist(createSignal<Answer>(), {
|
||||
key: `typebot-${props.context.typebot.id}-input-${props.chunkIndex}`,
|
||||
storage: props.context.storage,
|
||||
})
|
||||
const [formattedMessage, setFormattedMessage] = createSignal<string>()
|
||||
|
||||
const handleSubmit = async ({ label, value }: InputSubmitContent) => {
|
||||
setAnswer(label ?? value)
|
||||
props.onSubmit(value ?? label)
|
||||
const handleSubmit = async ({
|
||||
label,
|
||||
value,
|
||||
attachments,
|
||||
}: InputSubmitContent & Pick<Answer, 'attachments'>) => {
|
||||
setAnswer({
|
||||
text: props.block.type !== InputBlockType.FILE ? label ?? value : '',
|
||||
attachments,
|
||||
})
|
||||
props.onSubmit(
|
||||
value ?? label,
|
||||
props.block.type === InputBlockType.FILE ? undefined : attachments
|
||||
)
|
||||
}
|
||||
|
||||
const handleSkip = (label: string) => {
|
||||
setAnswer(label)
|
||||
setAnswer({ text: label })
|
||||
props.onSkip()
|
||||
}
|
||||
|
||||
@ -73,14 +82,15 @@ export const InputChatBlock = (props: Props) => {
|
||||
const formattedMessage = formattedMessages().findLast(
|
||||
(message) => props.chunkIndex === message.inputIndex
|
||||
)?.formattedMessage
|
||||
if (formattedMessage) setFormattedMessage(formattedMessage)
|
||||
if (formattedMessage && props.block.type !== InputBlockType.FILE)
|
||||
setAnswer((answer) => ({ ...answer, text: formattedMessage }))
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={answer() && !props.hasError}>
|
||||
<GuestBubble
|
||||
message={formattedMessage() ?? (answer() as string)}
|
||||
message={answer() as Answer}
|
||||
showAvatar={
|
||||
props.guestAvatar?.isEnabled ?? defaultGuestAvatarIsEnabled
|
||||
}
|
||||
@ -107,7 +117,7 @@ export const InputChatBlock = (props: Props) => {
|
||||
block={props.block}
|
||||
chunkIndex={props.chunkIndex}
|
||||
isInputPrefillEnabled={props.isInputPrefillEnabled}
|
||||
existingAnswer={props.hasError ? answer() : undefined}
|
||||
existingAnswer={props.hasError ? answer()?.text : undefined}
|
||||
onTransitionEnd={props.onTransitionEnd}
|
||||
onSubmit={handleSubmit}
|
||||
onSkip={handleSkip}
|
||||
@ -147,6 +157,7 @@ const Input = (props: {
|
||||
<TextInput
|
||||
block={props.block as TextInputBlock}
|
||||
defaultValue={getPrefilledValue()}
|
||||
context={props.context}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</Match>
|
||||
|
27
packages/embeds/js/src/components/Modal.tsx
Normal file
27
packages/embeds/js/src/components/Modal.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { Dialog } from '@ark-ui/solid'
|
||||
import { JSX } from 'solid-js'
|
||||
import { CloseIcon } from './icons/CloseIcon'
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean
|
||||
onClose?: () => void
|
||||
children: JSX.Element
|
||||
}
|
||||
export const Modal = (props: Props) => {
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={props.isOpen}
|
||||
lazyMount
|
||||
unmountOnExit
|
||||
onOpenChange={(e) => (!e.open ? props.onClose?.() : undefined)}
|
||||
>
|
||||
<Dialog.Backdrop class="fixed inset-0 bg-[rgba(0,0,0,0.5)] h-screen z-50" />
|
||||
<Dialog.Positioner class="fixed inset-0 z-50 flex items-center justify-center px-2">
|
||||
<Dialog.Content>{props.children}</Dialog.Content>
|
||||
<Dialog.CloseTrigger class="fixed top-2 right-2 z-50 rounded-md bg-white p-2 text-black">
|
||||
<CloseIcon class="w-6 h-6" />
|
||||
</Dialog.CloseTrigger>
|
||||
</Dialog.Positioner>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
@ -1,29 +1,41 @@
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { splitProps } from 'solid-js'
|
||||
import { splitProps, Switch, Match } from 'solid-js'
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
import { SendIcon } from './icons'
|
||||
import { Button } from './Button'
|
||||
import { isEmpty } from '@typebot.io/lib'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type SendButtonProps = {
|
||||
isDisabled?: boolean
|
||||
isLoading?: boolean
|
||||
disableIcon?: boolean
|
||||
class?: string
|
||||
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
export const SendButton = (props: SendButtonProps) => {
|
||||
const [local, others] = splitProps(props, ['disableIcon'])
|
||||
const showIcon =
|
||||
(isMobile() && !local.disableIcon) ||
|
||||
!props.children ||
|
||||
(typeof props.children === 'string' && isEmpty(props.children))
|
||||
return (
|
||||
<Button type="submit" {...others}>
|
||||
{(isMobile() && !local.disableIcon) ||
|
||||
!props.children ||
|
||||
(typeof props.children === 'string' && isEmpty(props.children)) ? (
|
||||
<SendIcon
|
||||
class={'send-icon flex ' + (local.disableIcon ? 'hidden' : '')}
|
||||
/>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
{...others}
|
||||
class={clsx('flex items-center', props.class)}
|
||||
aria-label={showIcon ? 'Send' : undefined}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={showIcon}>
|
||||
<SendIcon
|
||||
class={
|
||||
'send-icon flex w-6 h-6 ' + (local.disableIcon ? 'hidden' : '')
|
||||
}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={!showIcon}>{props.children}</Match>
|
||||
</Switch>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
103
packages/embeds/js/src/components/TextInputAddFileButton.tsx
Normal file
103
packages/embeds/js/src/components/TextInputAddFileButton.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import { Show } from 'solid-js'
|
||||
import { Menu } from '@ark-ui/solid'
|
||||
import { CameraIcon } from './icons/CameraIcon'
|
||||
import { FileIcon } from './icons/FileIcon'
|
||||
import { PictureIcon } from './icons/PictureIcon'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { PaperClipIcon } from './icons/PaperClipIcon'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Props = {
|
||||
onNewFiles: (files: FileList) => void
|
||||
class?: string
|
||||
}
|
||||
export const TextInputAddFileButton = (props: Props) => {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="file"
|
||||
id="document-upload"
|
||||
multiple
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
if (!e.currentTarget.files) return
|
||||
props.onNewFiles(e.currentTarget.files)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
id="photos-upload"
|
||||
accept="image/*"
|
||||
multiple
|
||||
class="hidden"
|
||||
onChange={(e) => {
|
||||
if (!e.currentTarget.files) return
|
||||
props.onNewFiles(e.currentTarget.files)
|
||||
}}
|
||||
/>
|
||||
<Show when={isMobile()}>
|
||||
<input
|
||||
type="file"
|
||||
id="camera-upload"
|
||||
class="hidden"
|
||||
multiple
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
onChange={(e) => {
|
||||
if (!e.currentTarget.files) return
|
||||
props.onNewFiles(e.currentTarget.files)
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Menu.Root>
|
||||
<Menu.Trigger
|
||||
class={clsx(
|
||||
'filter data-[state=open]:backdrop-brightness-90 hover:backdrop-brightness-95 transition rounded-md p-2 focus:outline-none',
|
||||
props.class
|
||||
)}
|
||||
aria-label="Add attachments"
|
||||
>
|
||||
<PaperClipIcon class="w-5" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Positioner>
|
||||
<Menu.Content class="p-3 gap-2 focus:outline-none">
|
||||
<Menu.Item
|
||||
value="document"
|
||||
asChild={(props) => (
|
||||
<label
|
||||
{...props()}
|
||||
for="document-upload"
|
||||
class="p-2 filter hover:brightness-95 flex gap-3 items-center"
|
||||
>
|
||||
<FileIcon class="w-4" /> Document
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
<Menu.Item
|
||||
value="photos"
|
||||
asChild={(props) => (
|
||||
<label
|
||||
{...props()}
|
||||
for="photos-upload"
|
||||
class="p-2 filter hover:brightness-95 flex gap-3 items-center"
|
||||
>
|
||||
<PictureIcon class="w-4" /> Photos & videos
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
<Show when={isMobile()}>
|
||||
<Menu.Item
|
||||
value="camera"
|
||||
class="p-2 filter hover:brightness-95 flex gap-3 items-center"
|
||||
>
|
||||
<CameraIcon class="w-4" />
|
||||
Camera
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Positioner>
|
||||
</Menu.Root>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,33 +1,105 @@
|
||||
import { Show } from 'solid-js'
|
||||
import { createSignal, For, Show } from 'solid-js'
|
||||
import { Avatar } from '../avatars/Avatar'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { Answer } from '@/types'
|
||||
import { Modal } from '../Modal'
|
||||
import { isNotEmpty } from '@typebot.io/lib'
|
||||
import { FilePreview } from '@/features/blocks/inputs/fileUpload/components/FilePreview'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Props = {
|
||||
message: string
|
||||
message: Answer
|
||||
showAvatar: boolean
|
||||
avatarSrc?: string
|
||||
hasHostAvatar: boolean
|
||||
}
|
||||
|
||||
export const GuestBubble = (props: Props) => (
|
||||
<div
|
||||
class="flex justify-end items-end animate-fade-in gap-2 guest-container"
|
||||
style={{
|
||||
'margin-left': props.hasHostAvatar
|
||||
? isMobile()
|
||||
? '28px'
|
||||
: '50px'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
class="px-4 py-2 whitespace-pre-wrap max-w-full typebot-guest-bubble"
|
||||
data-testid="guest-bubble"
|
||||
export const GuestBubble = (props: Props) => {
|
||||
const [clickedImageSrc, setClickedImageSrc] = createSignal<string>()
|
||||
|
||||
return (
|
||||
<div
|
||||
class="flex justify-end items-end animate-fade-in gap-2 guest-container"
|
||||
style={{
|
||||
'margin-left': props.hasHostAvatar
|
||||
? isMobile()
|
||||
? '28px'
|
||||
: '50px'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{props.message}
|
||||
</span>
|
||||
<Show when={props.showAvatar}>
|
||||
<Avatar initialAvatarSrc={props.avatarSrc} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
<div class="flex flex-col gap-1 items-end">
|
||||
<Show when={(props.message.attachments ?? []).length > 0}>
|
||||
<div
|
||||
class={clsx(
|
||||
'flex gap-1 overflow-auto max-w-[350px]',
|
||||
isMobile() ? 'flex-wrap justify-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<For
|
||||
each={props.message.attachments?.filter((attachment) =>
|
||||
attachment.type.startsWith('image')
|
||||
)}
|
||||
>
|
||||
{(attachment, idx) => (
|
||||
<img
|
||||
src={attachment.url}
|
||||
alt={`Attached image ${idx() + 1}`}
|
||||
class={clsx(
|
||||
'typebot-guest-bubble-image-attachment cursor-pointer',
|
||||
props.message.attachments!.filter((attachment) =>
|
||||
attachment.type.startsWith('image')
|
||||
).length > 1 && 'max-w-[90%]'
|
||||
)}
|
||||
onClick={() => setClickedImageSrc(attachment.url)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div
|
||||
class={clsx(
|
||||
'flex gap-1 overflow-auto max-w-[350px]',
|
||||
isMobile() ? 'flex-wrap justify-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
<For
|
||||
each={props.message.attachments?.filter(
|
||||
(attachment) => !attachment.type.startsWith('image')
|
||||
)}
|
||||
>
|
||||
{(attachment) => (
|
||||
<FilePreview
|
||||
file={{
|
||||
name: attachment.url.split('/').at(-1)!,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="p-[1px] whitespace-pre-wrap max-w-full typebot-guest-bubble flex flex-col"
|
||||
data-testid="guest-bubble"
|
||||
>
|
||||
<Show when={isNotEmpty(props.message.text)}>
|
||||
<span class="px-[15px] py-[7px]">{props.message.text}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={clickedImageSrc() !== undefined}
|
||||
onClose={() => setClickedImageSrc(undefined)}
|
||||
>
|
||||
<img
|
||||
src={clickedImageSrc()}
|
||||
alt="Attachment"
|
||||
style={{ 'border-radius': '6px' }}
|
||||
/>
|
||||
</Modal>
|
||||
<Show when={props.showAvatar}>
|
||||
<Avatar initialAvatarSrc={props.avatarSrc} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
17
packages/embeds/js/src/components/icons/CameraIcon.tsx
Normal file
17
packages/embeds/js/src/components/icons/CameraIcon.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const CameraIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2px"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</svg>
|
||||
)
|
17
packages/embeds/js/src/components/icons/FileIcon.tsx
Normal file
17
packages/embeds/js/src/components/icons/FileIcon.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const FileIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2px"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
</svg>
|
||||
)
|
18
packages/embeds/js/src/components/icons/PaperClipIcon.tsx
Normal file
18
packages/embeds/js/src/components/icons/PaperClipIcon.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const PaperClipIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
)
|
||||
}
|
19
packages/embeds/js/src/components/icons/PictureIcon.tsx
Normal file
19
packages/embeds/js/src/components/icons/PictureIcon.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const PictureIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2px"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<circle cx="10" cy="12" r="2" />
|
||||
<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22" />
|
||||
</svg>
|
||||
)
|
17
packages/embeds/js/src/components/icons/PlusIcon.tsx
Normal file
17
packages/embeds/js/src/components/icons/PlusIcon.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { JSX } from 'solid-js/jsx-runtime'
|
||||
|
||||
export const PlusIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2px"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
{...props}
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
)
|
@ -16,93 +16,94 @@ export const DateForm = (props: Props) => {
|
||||
parseDefaultValue(props.defaultValue ?? '')
|
||||
)
|
||||
|
||||
const submit = () => {
|
||||
if (inputValues().from === '' && inputValues().to === '') return
|
||||
props.onSubmit({
|
||||
value: `${inputValues().from}${
|
||||
props.options?.isRange ? ` to ${inputValues().to}` : ''
|
||||
}`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center">
|
||||
<form
|
||||
class={clsx(
|
||||
'flex justify-between typebot-input pr-2',
|
||||
props.options?.isRange ? 'items-end' : 'items-center'
|
||||
)}
|
||||
onSubmit={(e) => {
|
||||
if (inputValues().from === '' && inputValues().to === '') return
|
||||
e.preventDefault()
|
||||
props.onSubmit({
|
||||
value: `${inputValues().from}${
|
||||
props.options?.isRange ? ` to ${inputValues().to}` : ''
|
||||
}`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class={
|
||||
'flex items-center p-4 ' +
|
||||
(props.options?.isRange ? 'pb-0 gap-2' : '')
|
||||
<div class="typebot-input-form flex gap-2 items-end">
|
||||
<form
|
||||
class={clsx(
|
||||
'flex typebot-input',
|
||||
props.options?.isRange ? 'items-end' : 'items-center'
|
||||
)}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class={
|
||||
'flex items-center p-4 ' +
|
||||
(props.options?.isRange ? 'pb-0 gap-2' : '')
|
||||
}
|
||||
>
|
||||
{props.options?.isRange && (
|
||||
<p class="font-semibold">
|
||||
{props.options.labels?.from ??
|
||||
defaultDateInputOptions.labels.from}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
class="focus:outline-none flex-1 w-full text-input typebot-date-input"
|
||||
style={{
|
||||
'min-height': '32px',
|
||||
'min-width': '100px',
|
||||
'font-size': '16px',
|
||||
}}
|
||||
value={inputValues().from}
|
||||
type={props.options?.hasTime ? 'datetime-local' : 'date'}
|
||||
onChange={(e) =>
|
||||
setInputValues({
|
||||
...inputValues(),
|
||||
from: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
{props.options?.isRange && (
|
||||
min={props.options?.min}
|
||||
max={props.options?.max}
|
||||
data-testid="from-date"
|
||||
/>
|
||||
</div>
|
||||
{props.options?.isRange && (
|
||||
<div class="flex items-center p-4">
|
||||
{props.options.isRange && (
|
||||
<p class="font-semibold">
|
||||
{props.options.labels?.from ??
|
||||
defaultDateInputOptions.labels.from}
|
||||
{props.options.labels?.to ??
|
||||
defaultDateInputOptions.labels.to}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
class="focus:outline-none flex-1 w-full text-input typebot-date-input"
|
||||
class="focus:outline-none flex-1 w-full text-input ml-2 typebot-date-input"
|
||||
style={{
|
||||
'min-height': '32px',
|
||||
'min-width': '100px',
|
||||
'font-size': '16px',
|
||||
}}
|
||||
value={inputValues().from}
|
||||
type={props.options?.hasTime ? 'datetime-local' : 'date'}
|
||||
value={inputValues().to}
|
||||
type={props.options.hasTime ? 'datetime-local' : 'date'}
|
||||
onChange={(e) =>
|
||||
setInputValues({
|
||||
...inputValues(),
|
||||
from: e.currentTarget.value,
|
||||
to: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
min={props.options?.min}
|
||||
max={props.options?.max}
|
||||
data-testid="from-date"
|
||||
data-testid="to-date"
|
||||
/>
|
||||
</div>
|
||||
{props.options?.isRange && (
|
||||
<div class="flex items-center p-4">
|
||||
{props.options.isRange && (
|
||||
<p class="font-semibold">
|
||||
{props.options.labels?.to ??
|
||||
defaultDateInputOptions.labels.to}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
class="focus:outline-none flex-1 w-full text-input ml-2 typebot-date-input"
|
||||
style={{
|
||||
'min-height': '32px',
|
||||
'min-width': '100px',
|
||||
'font-size': '16px',
|
||||
}}
|
||||
value={inputValues().to}
|
||||
type={props.options.hasTime ? 'datetime-local' : 'date'}
|
||||
onChange={(e) =>
|
||||
setInputValues({
|
||||
...inputValues(),
|
||||
to: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
min={props.options?.min}
|
||||
max={props.options?.max}
|
||||
data-testid="to-date"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SendButton class="my-2 ml-2">
|
||||
{props.options?.labels?.button}
|
||||
</SendButton>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<SendButton class="h-[56px]" on:click={submit}>
|
||||
{props.options?.labels?.button}
|
||||
</SendButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -49,25 +49,23 @@ export const EmailInput = (props: Props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
class={'flex items-end justify-between pr-2 typebot-input w-full'}
|
||||
data-testid="input"
|
||||
style={{
|
||||
'max-width': '350px',
|
||||
}}
|
||||
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<ShortTextInput
|
||||
ref={inputRef}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ??
|
||||
defaultEmailInputOptions.labels.placeholder
|
||||
}
|
||||
onInput={handleInput}
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
/>
|
||||
<SendButton type="button" class="my-2 ml-2" on:click={submit}>
|
||||
<div class={'flex typebot-input w-full'}>
|
||||
<ShortTextInput
|
||||
ref={inputRef}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ??
|
||||
defaultEmailInputOptions.labels.placeholder
|
||||
}
|
||||
onInput={handleInput}
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
<SendButton type="button" class="h-[56px]" on:click={submit}>
|
||||
{props.block.options?.labels?.button}
|
||||
</SendButton>
|
||||
</div>
|
||||
|
@ -0,0 +1,80 @@
|
||||
import { FileIcon } from '@/components/icons/FileIcon'
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Props = {
|
||||
file: { name: string }
|
||||
}
|
||||
|
||||
export const FilePreview = (props: Props) => {
|
||||
const fileColor = getFileAssociatedColor(props.file)
|
||||
|
||||
return (
|
||||
<div
|
||||
class={
|
||||
'flex items-center gap-4 border bg-white border-gray-200 rounded-md p-2 text-gray-900 min-w-[250px]'
|
||||
}
|
||||
>
|
||||
<div
|
||||
class={clsx(
|
||||
'rounded-md text-white p-2 flex items-center',
|
||||
fileColor === 'pink' && 'bg-pink-400',
|
||||
fileColor === 'blue' && 'bg-blue-400',
|
||||
fileColor === 'green' && 'bg-green-400',
|
||||
fileColor === 'gray' && 'bg-gray-400',
|
||||
fileColor === 'orange' && 'bg-orange-400'
|
||||
)}
|
||||
>
|
||||
<FileIcon class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-md font-semibold text-sm">{props.file.name}</span>
|
||||
<span class="text-gray-500 text-xs">
|
||||
{formatFileExtensionHumanReadable(props.file)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const formatFileExtensionHumanReadable = (file: { name: string }) => {
|
||||
const extension = file.name.split('.').pop()
|
||||
switch (extension) {
|
||||
case 'pdf':
|
||||
return 'PDF'
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'Word'
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
case 'csv':
|
||||
return 'Sheet'
|
||||
case 'json':
|
||||
return 'JSON'
|
||||
case 'md':
|
||||
return 'Markdown'
|
||||
default:
|
||||
return 'DOCUMENT'
|
||||
}
|
||||
}
|
||||
|
||||
const getFileAssociatedColor = (file: {
|
||||
name: string
|
||||
}): 'pink' | 'blue' | 'green' | 'gray' | 'orange' => {
|
||||
const extension = file.name.split('.').pop()
|
||||
if (!extension) return 'gray'
|
||||
switch (extension) {
|
||||
case 'pdf':
|
||||
return 'pink'
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'blue'
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
case 'csv':
|
||||
return 'green'
|
||||
case 'json':
|
||||
return 'orange'
|
||||
default:
|
||||
return 'gray'
|
||||
}
|
||||
}
|
@ -1,14 +1,16 @@
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { BotContext, InputSubmitContent } from '@/types'
|
||||
import { FileInputBlock } from '@typebot.io/schemas'
|
||||
import { createSignal, Match, Show, Switch } from 'solid-js'
|
||||
import { createSignal, Match, Show, Switch, For } from 'solid-js'
|
||||
import { Button } from '@/components/Button'
|
||||
import { Spinner } from '@/components/Spinner'
|
||||
import { uploadFiles } from '../helpers/uploadFiles'
|
||||
import { guessApiHost } from '@/utils/guessApiHost'
|
||||
import { getRuntimeVariable } from '@typebot.io/env/getRuntimeVariable'
|
||||
import { defaultFileInputOptions } from '@typebot.io/schemas/features/blocks/inputs/file/constants'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { SelectedFile } from './SelectedFile'
|
||||
import { sanitizeNewFile } from '../helpers/sanitizeSelectedFiles'
|
||||
import { toaster } from '@/utils/toaster'
|
||||
|
||||
type Props = {
|
||||
context: BotContext
|
||||
@ -22,39 +24,34 @@ export const FileUploadForm = (props: Props) => {
|
||||
const [isUploading, setIsUploading] = createSignal(false)
|
||||
const [uploadProgressPercent, setUploadProgressPercent] = createSignal(0)
|
||||
const [isDraggingOver, setIsDraggingOver] = createSignal(false)
|
||||
const [errorMessage, setErrorMessage] = createSignal<string>()
|
||||
|
||||
const onNewFiles = (files: FileList) => {
|
||||
setErrorMessage(undefined)
|
||||
const newFiles = Array.from(files)
|
||||
const sizeLimit =
|
||||
props.block.options && 'sizeLimit' in props.block.options
|
||||
? props.block.options?.sizeLimit ??
|
||||
getRuntimeVariable('NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE')
|
||||
: undefined
|
||||
if (
|
||||
sizeLimit &&
|
||||
newFiles.some((file) => file.size > sizeLimit * 1024 * 1024)
|
||||
)
|
||||
return setErrorMessage(`A file is larger than ${sizeLimit}MB`)
|
||||
if (!props.block.options?.isMultipleAllowed && files)
|
||||
.map((file) =>
|
||||
sanitizeNewFile({
|
||||
existingFiles: selectedFiles(),
|
||||
newFile: file,
|
||||
params: {
|
||||
sizeLimit:
|
||||
props.block.options && 'sizeLimit' in props.block.options
|
||||
? props.block.options.sizeLimit
|
||||
: undefined,
|
||||
},
|
||||
onError: ({ description, title }) =>
|
||||
toaster.create({
|
||||
title,
|
||||
description,
|
||||
}),
|
||||
})
|
||||
)
|
||||
.filter(isDefined)
|
||||
|
||||
if (newFiles.length === 0) return
|
||||
|
||||
if (!props.block.options?.isMultipleAllowed)
|
||||
return startSingleFileUpload(newFiles[0])
|
||||
if (selectedFiles().length === 0) {
|
||||
setSelectedFiles(newFiles)
|
||||
return
|
||||
}
|
||||
const parsedNewFiles = newFiles.map((newFile) => {
|
||||
let fileName = newFile.name
|
||||
let counter = 1
|
||||
while (selectedFiles().some((file) => file.name === fileName)) {
|
||||
const dotIndex = newFile.name.lastIndexOf('.')
|
||||
const extension = dotIndex !== -1 ? newFile.name.slice(dotIndex) : ''
|
||||
fileName = `${newFile.name.slice(0, dotIndex)}(${counter})${extension}`
|
||||
counter++
|
||||
}
|
||||
return new File([newFile], fileName, { type: newFile.type })
|
||||
})
|
||||
setSelectedFiles([...selectedFiles(), ...parsedNewFiles])
|
||||
|
||||
setSelectedFiles([...selectedFiles(), ...newFiles])
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: SubmitEvent) => {
|
||||
@ -64,13 +61,6 @@ export const FileUploadForm = (props: Props) => {
|
||||
}
|
||||
|
||||
const startSingleFileUpload = async (file: File) => {
|
||||
if (props.context.isPreview || !props.context.resultId)
|
||||
return props.onSubmit({
|
||||
label:
|
||||
props.block.options?.labels?.success?.single ??
|
||||
defaultFileInputOptions.labels.success.single,
|
||||
value: 'http://fake-upload-url.com',
|
||||
})
|
||||
setIsUploading(true)
|
||||
const urls = await uploadFiles({
|
||||
apiHost:
|
||||
@ -86,31 +76,17 @@ export const FileUploadForm = (props: Props) => {
|
||||
],
|
||||
})
|
||||
setIsUploading(false)
|
||||
if (urls.length)
|
||||
if (urls.length && urls[0])
|
||||
return props.onSubmit({
|
||||
label:
|
||||
props.block.options?.labels?.success?.single ??
|
||||
defaultFileInputOptions.labels.success.single,
|
||||
value: urls[0] ? encodeUrl(urls[0]) : '',
|
||||
value: urls[0] ? encodeUrl(urls[0].url) : '',
|
||||
attachments: [{ type: file.type, url: urls[0]!.url }],
|
||||
})
|
||||
setErrorMessage('An error occured while uploading the file')
|
||||
toaster.create({ description: 'An error occured while uploading the file' })
|
||||
}
|
||||
const startFilesUpload = async (files: File[]) => {
|
||||
const resultId = props.context.resultId
|
||||
if (props.context.isPreview || !resultId)
|
||||
return props.onSubmit({
|
||||
label:
|
||||
files.length > 1
|
||||
? (
|
||||
props.block.options?.labels?.success?.multiple ??
|
||||
defaultFileInputOptions.labels.success.multiple
|
||||
).replaceAll('{total}', files.length.toString())
|
||||
: props.block.options?.labels?.success?.single ??
|
||||
defaultFileInputOptions.labels.success.single,
|
||||
value: files
|
||||
.map((_, idx) => `http://fake-upload-url.com/${idx}`)
|
||||
.join(', '),
|
||||
})
|
||||
setIsUploading(true)
|
||||
const urls = await uploadFiles({
|
||||
apiHost:
|
||||
@ -127,7 +103,9 @@ export const FileUploadForm = (props: Props) => {
|
||||
setIsUploading(false)
|
||||
setUploadProgressPercent(0)
|
||||
if (urls.length !== files.length)
|
||||
return setErrorMessage('An error occured while uploading the files')
|
||||
return toaster.create({
|
||||
description: 'An error occured while uploading the files',
|
||||
})
|
||||
props.onSubmit({
|
||||
label:
|
||||
urls.length > 1
|
||||
@ -137,7 +115,11 @@ export const FileUploadForm = (props: Props) => {
|
||||
).replaceAll('{total}', urls.length.toString())
|
||||
: props.block.options?.labels?.success?.single ??
|
||||
defaultFileInputOptions.labels.success.single,
|
||||
value: urls.filter(isDefined).map(encodeUrl).join(', '),
|
||||
value: urls
|
||||
.filter(isDefined)
|
||||
.map(({ url }) => encodeUrl(url))
|
||||
.join(', '),
|
||||
attachments: urls.filter(isDefined),
|
||||
})
|
||||
}
|
||||
|
||||
@ -162,6 +144,12 @@ export const FileUploadForm = (props: Props) => {
|
||||
props.block.options?.labels?.skip ?? defaultFileInputOptions.labels.skip
|
||||
)
|
||||
|
||||
const removeSelectedFile = (index: number) => {
|
||||
setSelectedFiles((selectedFiles) =>
|
||||
selectedFiles.filter((_, i) => i !== index)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form class="flex flex-col w-full gap-2" onSubmit={handleSubmit}>
|
||||
<label
|
||||
@ -192,17 +180,24 @@ export const FileUploadForm = (props: Props) => {
|
||||
</Match>
|
||||
<Match when={!isUploading()}>
|
||||
<>
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<div class="flex flex-col justify-center items-center gap-4 max-w-[90%]">
|
||||
<Show when={selectedFiles().length} fallback={<UploadIcon />}>
|
||||
<span class="relative">
|
||||
<FileIcon />
|
||||
<div
|
||||
class="total-files-indicator flex items-center justify-center absolute -right-1 rounded-full px-1 w-4 h-4"
|
||||
style={{ bottom: '5px' }}
|
||||
>
|
||||
{selectedFiles().length}
|
||||
</div>
|
||||
</span>
|
||||
<div
|
||||
class="p-4 flex gap-2 border-gray-200 border overflow-auto bg-white rounded-md w-full"
|
||||
on:click={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<For each={selectedFiles()}>
|
||||
{(file, index) => (
|
||||
<SelectedFile
|
||||
file={file}
|
||||
onRemoveClick={() => removeSelectedFile(index())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<p
|
||||
class="text-sm text-gray-500 text-center"
|
||||
@ -269,9 +264,6 @@ export const FileUploadForm = (props: Props) => {
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={errorMessage()}>
|
||||
<p class="text-red-500 text-sm">{errorMessage()}</p>
|
||||
</Show>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@ -287,7 +279,7 @@ const UploadIcon = () => (
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="mb-3 text-gray-500"
|
||||
class="text-gray-500"
|
||||
>
|
||||
<polyline points="16 16 12 12 8 16" />
|
||||
<line x1="12" y1="12" x2="12" y2="21" />
|
||||
@ -296,24 +288,6 @@ const UploadIcon = () => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FileIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="mb-3 text-gray-500"
|
||||
>
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
|
||||
<polyline points="13 2 13 9 20 9" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const encodeUrl = (url: string): string => {
|
||||
const fileName = url.split('/').pop()
|
||||
if (!fileName) return url
|
||||
|
@ -0,0 +1,91 @@
|
||||
import {
|
||||
Switch,
|
||||
Match,
|
||||
Show,
|
||||
createSignal,
|
||||
createEffect,
|
||||
onCleanup,
|
||||
} from 'solid-js'
|
||||
import { CloseIcon } from '@/components/icons/CloseIcon'
|
||||
import { FilePreview } from './FilePreview'
|
||||
import { Progress } from '@ark-ui/solid'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
|
||||
export const SelectedFile = (props: {
|
||||
file: File
|
||||
uploadProgressPercent?: number
|
||||
onRemoveClick: () => void
|
||||
}) => {
|
||||
return (
|
||||
<div class="relative group flex-shrink-0">
|
||||
<Switch>
|
||||
<Match when={props.file.type.startsWith('image')}>
|
||||
<img
|
||||
src={URL.createObjectURL(props.file)}
|
||||
alt={props.file.name}
|
||||
class="rounded-md object-cover w-[58px] h-[58px]"
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<FilePreview file={props.file} />
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
<button
|
||||
class="absolute -right-2 p-0.5 -top-2 rounded-full bg-gray-200 text-black border border-gray-400 opacity-1 sm:opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
on:click={props.onRemoveClick}
|
||||
aria-label="Remove attachment"
|
||||
>
|
||||
<CloseIcon class="w-4" />
|
||||
</button>
|
||||
<Show
|
||||
when={
|
||||
isDefined(props.uploadProgressPercent) &&
|
||||
props.uploadProgressPercent !== 100
|
||||
}
|
||||
>
|
||||
<UploadOverlay progressPercent={props.uploadProgressPercent} />
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const UploadOverlay = (props: { progressPercent?: number }) => {
|
||||
const [progressPercent, setProgressPercent] = createSignal(
|
||||
props.progressPercent ?? 0
|
||||
)
|
||||
|
||||
let interval: NodeJS.Timer | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (props.progressPercent === 20) {
|
||||
const incrementProgress = () => {
|
||||
if (progressPercent() < 100) {
|
||||
setProgressPercent(
|
||||
(prev) => prev + (Math.floor(Math.random() * 10) + 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interval = setInterval(incrementProgress, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="absolute w-full h-full inset-0 bg-black/20 rounded-md">
|
||||
<Progress.Root
|
||||
value={progressPercent()}
|
||||
class="flex items-center justify-center"
|
||||
>
|
||||
<Progress.Circle>
|
||||
<Progress.CircleTrack />
|
||||
<Progress.CircleRange />
|
||||
</Progress.Circle>
|
||||
</Progress.Root>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import { getRuntimeVariable } from '@typebot.io/env/getRuntimeVariable'
|
||||
|
||||
type Props = {
|
||||
newFile: File
|
||||
existingFiles: File[]
|
||||
params: {
|
||||
sizeLimit?: number
|
||||
}
|
||||
onError: (message: { title?: string; description: string }) => void
|
||||
}
|
||||
export const sanitizeNewFile = ({
|
||||
newFile,
|
||||
existingFiles,
|
||||
params,
|
||||
onError,
|
||||
}: Props): File | undefined => {
|
||||
const sizeLimit =
|
||||
params.sizeLimit ??
|
||||
getRuntimeVariable('NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE')
|
||||
|
||||
if (sizeLimit && newFile.size > sizeLimit * 1024 * 1024) {
|
||||
onError({
|
||||
title: 'File too large',
|
||||
description: `${newFile.name} is larger than ${sizeLimit}MB`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (existingFiles.length === 0) return newFile
|
||||
|
||||
let fileName = newFile.name
|
||||
let counter = 1
|
||||
while (existingFiles.some((file) => file.name === fileName)) {
|
||||
const dotIndex = newFile.name.lastIndexOf('.')
|
||||
const extension = dotIndex !== -1 ? newFile.name.slice(dotIndex) : ''
|
||||
fileName = `${newFile.name.slice(0, dotIndex)}(${counter})${extension}`
|
||||
counter++
|
||||
}
|
||||
return new File([newFile], fileName, { type: newFile.type })
|
||||
}
|
@ -9,20 +9,24 @@ type UploadFileProps = {
|
||||
fileName: string
|
||||
}
|
||||
}[]
|
||||
onUploadProgress?: (percent: number) => void
|
||||
onUploadProgress?: (props: { fileIndex: number; progress: number }) => void
|
||||
}
|
||||
|
||||
type UrlList = (string | null)[]
|
||||
type UrlList = ({
|
||||
url: string
|
||||
type: string
|
||||
} | null)[]
|
||||
|
||||
export const uploadFiles = async ({
|
||||
apiHost,
|
||||
files,
|
||||
onUploadProgress,
|
||||
}: UploadFileProps): Promise<UrlList> => {
|
||||
const urls = []
|
||||
const urls: UrlList = []
|
||||
let i = 0
|
||||
for (const { input, file } of files) {
|
||||
onUploadProgress && onUploadProgress((i / files.length) * 100)
|
||||
onUploadProgress &&
|
||||
onUploadProgress({ progress: (i / files.length) * 100, fileIndex: i })
|
||||
i += 1
|
||||
const { data } = await sendRequest<{
|
||||
presignedUrl: string
|
||||
@ -52,7 +56,7 @@ export const uploadFiles = async ({
|
||||
|
||||
if (!upload.ok) continue
|
||||
|
||||
urls.push(data.fileUrl)
|
||||
urls.push({ url: data.fileUrl, type: file.type })
|
||||
}
|
||||
}
|
||||
return urls
|
||||
|
@ -52,34 +52,32 @@ export const NumberInput = (props: NumberInputProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
class={'flex items-end justify-between pr-2 typebot-input w-full'}
|
||||
data-testid="input"
|
||||
style={{
|
||||
'max-width': '350px',
|
||||
}}
|
||||
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
|
||||
style={{ 'font-size': '16px', appearance: 'auto' }}
|
||||
value={staticValue}
|
||||
// @ts-expect-error not defined
|
||||
// eslint-disable-next-line solid/jsx-no-undef
|
||||
use:bindValue
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ??
|
||||
defaultNumberInputOptions.labels.placeholder
|
||||
}
|
||||
onInput={(e) => {
|
||||
setInputValue(targetValue(e.currentTarget))
|
||||
}}
|
||||
type="number"
|
||||
min={props.block.options?.min}
|
||||
max={props.block.options?.max}
|
||||
step={props.block.options?.step ?? 'any'}
|
||||
/>
|
||||
<SendButton type="button" class="my-2 ml-2" on:click={submit}>
|
||||
<div class={'flex typebot-input w-full'}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
|
||||
style={{ 'font-size': '16px', appearance: 'auto' }}
|
||||
value={staticValue}
|
||||
// @ts-expect-error not defined
|
||||
// eslint-disable-next-line solid/jsx-no-undef
|
||||
use:bindValue
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ??
|
||||
defaultNumberInputOptions.labels.placeholder
|
||||
}
|
||||
onInput={(e) => {
|
||||
setInputValue(targetValue(e.currentTarget))
|
||||
}}
|
||||
type="number"
|
||||
min={props.block.options?.min}
|
||||
max={props.block.options?.max}
|
||||
step={props.block.options?.step ?? 'any'}
|
||||
/>
|
||||
</div>
|
||||
<SendButton type="button" class="h-[56px]" on:click={submit}>
|
||||
{props.block.options?.labels?.button}
|
||||
</SendButton>
|
||||
</div>
|
||||
|
@ -106,14 +106,10 @@ export const PhoneInput = (props: PhoneInputProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
class={'flex items-end justify-between pr-2 typebot-input'}
|
||||
data-testid="input"
|
||||
style={{
|
||||
'max-width': '400px',
|
||||
}}
|
||||
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<div class="flex">
|
||||
<div class={'flex typebot-input w-full'}>
|
||||
<div class="relative typebot-country-select flex justify-center items-center">
|
||||
<div class="pl-2 pr-1 flex items-center gap-2">
|
||||
<span>
|
||||
@ -156,8 +152,7 @@ export const PhoneInput = (props: PhoneInputProps) => {
|
||||
autofocus={!isMobile()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SendButton type="button" class="my-2 ml-2" on:click={submit}>
|
||||
<SendButton type="button" class="h-[56px]" on:click={submit}>
|
||||
{props.labels?.button}
|
||||
</SendButton>
|
||||
</div>
|
||||
|
@ -1,21 +1,35 @@
|
||||
import { Textarea, ShortTextInput } from '@/components'
|
||||
import { SendButton } from '@/components/SendButton'
|
||||
import { CommandData } from '@/features/commands'
|
||||
import { InputSubmitContent } from '@/types'
|
||||
import { Answer, BotContext, InputSubmitContent } from '@/types'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import type { TextInputBlock } from '@typebot.io/schemas'
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||
import { For, Show, createSignal, onCleanup, onMount } from 'solid-js'
|
||||
import { defaultTextInputOptions } from '@typebot.io/schemas/features/blocks/inputs/text/constants'
|
||||
import clsx from 'clsx'
|
||||
import { TextInputAddFileButton } from '@/components/TextInputAddFileButton'
|
||||
import { SelectedFile } from '../../fileUpload/components/SelectedFile'
|
||||
import { sanitizeNewFile } from '../../fileUpload/helpers/sanitizeSelectedFiles'
|
||||
import { getRuntimeVariable } from '@typebot.io/env/getRuntimeVariable'
|
||||
import { toaster } from '@/utils/toaster'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { uploadFiles } from '../../fileUpload/helpers/uploadFiles'
|
||||
import { guessApiHost } from '@/utils/guessApiHost'
|
||||
|
||||
type Props = {
|
||||
block: TextInputBlock
|
||||
defaultValue?: string
|
||||
context: BotContext
|
||||
onSubmit: (value: InputSubmitContent) => void
|
||||
}
|
||||
|
||||
export const TextInput = (props: Props) => {
|
||||
const [inputValue, setInputValue] = createSignal(props.defaultValue ?? '')
|
||||
const [selectedFiles, setSelectedFiles] = createSignal<File[]>([])
|
||||
const [uploadProgress, setUploadProgress] = createSignal<
|
||||
{ fileIndex: number; progress: number } | undefined
|
||||
>(undefined)
|
||||
const [isDraggingOver, setIsDraggingOver] = createSignal(false)
|
||||
let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined
|
||||
|
||||
const handleInput = (inputValue: string) => setInputValue(inputValue)
|
||||
@ -23,10 +37,30 @@ export const TextInput = (props: Props) => {
|
||||
const checkIfInputIsValid = () =>
|
||||
inputRef?.value !== '' && inputRef?.reportValidity()
|
||||
|
||||
const submit = () => {
|
||||
if (checkIfInputIsValid())
|
||||
props.onSubmit({ value: inputRef?.value ?? inputValue() })
|
||||
else inputRef?.focus()
|
||||
const submit = async () => {
|
||||
if (checkIfInputIsValid()) {
|
||||
let attachments: Answer['attachments']
|
||||
if (selectedFiles().length > 0) {
|
||||
setUploadProgress(undefined)
|
||||
const urls = await uploadFiles({
|
||||
apiHost:
|
||||
props.context.apiHost ?? guessApiHost({ ignoreChatApiUrl: true }),
|
||||
files: selectedFiles().map((file) => ({
|
||||
file: file,
|
||||
input: {
|
||||
sessionId: props.context.sessionId,
|
||||
fileName: file.name,
|
||||
},
|
||||
})),
|
||||
onUploadProgress: setUploadProgress,
|
||||
})
|
||||
attachments = urls?.filter(isDefined)
|
||||
}
|
||||
props.onSubmit({
|
||||
value: inputRef?.value ?? inputValue(),
|
||||
attachments,
|
||||
})
|
||||
} else inputRef?.focus()
|
||||
}
|
||||
|
||||
const submitWhenEnter = (e: KeyboardEvent) => {
|
||||
@ -57,41 +91,139 @@ export const TextInput = (props: Props) => {
|
||||
if (data.command === 'setInputValue') setInputValue(data.value)
|
||||
}
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDraggingOver(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = () => setIsDraggingOver(false)
|
||||
|
||||
const handleDropFile = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!e.dataTransfer?.files) return
|
||||
onNewFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const onNewFiles = (files: FileList) => {
|
||||
const newFiles = Array.from(files)
|
||||
.map((file) =>
|
||||
sanitizeNewFile({
|
||||
existingFiles: selectedFiles(),
|
||||
newFile: file,
|
||||
params: {
|
||||
sizeLimit: getRuntimeVariable(
|
||||
'NEXT_PUBLIC_BOT_FILE_UPLOAD_MAX_SIZE'
|
||||
),
|
||||
},
|
||||
onError: ({ description, title }) => {
|
||||
toaster.create({
|
||||
description,
|
||||
title,
|
||||
})
|
||||
},
|
||||
})
|
||||
)
|
||||
.filter(isDefined)
|
||||
|
||||
if (newFiles.length === 0) return
|
||||
|
||||
setSelectedFiles((selectedFiles) => [...newFiles, ...selectedFiles])
|
||||
}
|
||||
|
||||
const removeSelectedFile = (index: number) => {
|
||||
setSelectedFiles((selectedFiles) =>
|
||||
selectedFiles.filter((_, i) => i !== index)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={clsx(
|
||||
'flex justify-between pr-2 typebot-input w-full',
|
||||
props.block.options?.isLong ? 'items-end' : 'items-center'
|
||||
)}
|
||||
data-testid="input"
|
||||
style={{
|
||||
'max-width': props.block.options?.isLong ? undefined : '350px',
|
||||
}}
|
||||
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
|
||||
onKeyDown={submitWhenEnter}
|
||||
onDrop={handleDropFile}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
>
|
||||
{props.block.options?.isLong ? (
|
||||
<Textarea
|
||||
ref={inputRef as HTMLTextAreaElement}
|
||||
onInput={handleInput}
|
||||
onKeyDown={submitIfCtrlEnter}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ??
|
||||
defaultTextInputOptions.labels.placeholder
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ShortTextInput
|
||||
ref={inputRef as HTMLInputElement}
|
||||
onInput={handleInput}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ??
|
||||
defaultTextInputOptions.labels.placeholder
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SendButton type="button" class="my-2 ml-2" on:click={submit}>
|
||||
<div
|
||||
class={clsx(
|
||||
'typebot-input flex-col w-full',
|
||||
isDraggingOver() && 'filter brightness-95'
|
||||
)}
|
||||
>
|
||||
<Show when={selectedFiles().length}>
|
||||
<div
|
||||
class="p-2 flex gap-2 border-gray-100 overflow-auto"
|
||||
style={{ 'border-bottom-width': '1px' }}
|
||||
>
|
||||
<For each={selectedFiles()}>
|
||||
{(file, index) => (
|
||||
<SelectedFile
|
||||
file={file}
|
||||
uploadProgressPercent={
|
||||
uploadProgress()
|
||||
? uploadProgress()?.fileIndex === index()
|
||||
? 20
|
||||
: index() < (uploadProgress()?.fileIndex ?? 0)
|
||||
? 100
|
||||
: 0
|
||||
: undefined
|
||||
}
|
||||
onRemoveClick={() => removeSelectedFile(index())}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class={clsx(
|
||||
'flex justify-between px-2',
|
||||
props.block.options?.isLong ? 'items-end' : 'items-center'
|
||||
)}
|
||||
>
|
||||
{props.block.options?.isLong ? (
|
||||
<Textarea
|
||||
ref={inputRef as HTMLTextAreaElement}
|
||||
onInput={handleInput}
|
||||
onKeyDown={submitIfCtrlEnter}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ??
|
||||
defaultTextInputOptions.labels.placeholder
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ShortTextInput
|
||||
ref={inputRef as HTMLInputElement}
|
||||
onInput={handleInput}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ??
|
||||
defaultTextInputOptions.labels.placeholder
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Show
|
||||
when={
|
||||
(props.block.options?.attachments?.isEnabled ??
|
||||
defaultTextInputOptions.attachments.isEnabled) &&
|
||||
props.block.options?.attachments?.saveVariableId
|
||||
}
|
||||
>
|
||||
<TextInputAddFileButton
|
||||
onNewFiles={onNewFiles}
|
||||
class={clsx(props.block.options?.isLong ? 'ml-2' : undefined)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SendButton
|
||||
type="button"
|
||||
on:click={submit}
|
||||
isDisabled={Boolean(uploadProgress())}
|
||||
class="h-[56px]"
|
||||
>
|
||||
{props.block.options?.labels?.button}
|
||||
</SendButton>
|
||||
</div>
|
||||
|
@ -56,25 +56,23 @@ export const UrlInput = (props: Props) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
class={'flex items-end justify-between pr-2 typebot-input w-full'}
|
||||
data-testid="input"
|
||||
style={{
|
||||
'max-width': '350px',
|
||||
}}
|
||||
class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]"
|
||||
onKeyDown={submitWhenEnter}
|
||||
>
|
||||
<ShortTextInput
|
||||
ref={inputRef as HTMLInputElement}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ??
|
||||
defaultUrlInputOptions.labels.placeholder
|
||||
}
|
||||
onInput={handleInput}
|
||||
type="url"
|
||||
autocomplete="url"
|
||||
/>
|
||||
<SendButton type="button" class="my-2 ml-2" on:click={submit}>
|
||||
<div class={'flex typebot-input w-full'}>
|
||||
<ShortTextInput
|
||||
ref={inputRef as HTMLInputElement}
|
||||
value={inputValue()}
|
||||
placeholder={
|
||||
props.block.options?.labels?.placeholder ??
|
||||
defaultUrlInputOptions.labels.placeholder
|
||||
}
|
||||
onInput={handleInput}
|
||||
type="url"
|
||||
autocomplete="url"
|
||||
/>
|
||||
</div>
|
||||
<SendButton type="button" class="h-[56px]" on:click={submit}>
|
||||
{props.block.options?.labels?.button}
|
||||
</SendButton>
|
||||
</div>
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
removeBotOpenedStateInStorage,
|
||||
setBotOpenedStateInStorage,
|
||||
} from '@/utils/storage'
|
||||
import { EnvironmentProvider } from '@ark-ui/solid'
|
||||
|
||||
export type BubbleProps = BotProps &
|
||||
BubbleParams & {
|
||||
@ -157,59 +158,65 @@ export const Bubble = (props: BubbleProps) => {
|
||||
|
||||
return (
|
||||
<Show when={isMounted()}>
|
||||
<style>{styles}</style>
|
||||
<Show when={isPreviewMessageDisplayed()}>
|
||||
<PreviewMessage
|
||||
{...previewMessage()}
|
||||
placement={bubbleProps.theme?.placement}
|
||||
previewMessageTheme={bubbleProps.theme?.previewMessage}
|
||||
buttonSize={buttonSize()}
|
||||
onClick={handlePreviewMessageClick}
|
||||
onCloseClick={hideMessage}
|
||||
/>
|
||||
</Show>
|
||||
<BubbleButton
|
||||
{...bubbleProps.theme?.button}
|
||||
placement={bubbleProps.theme?.placement}
|
||||
toggleBot={toggleBot}
|
||||
isBotOpened={isBotOpened()}
|
||||
buttonSize={buttonSize()}
|
||||
/>
|
||||
<div ref={progressBarContainerRef} />
|
||||
<div
|
||||
part="bot"
|
||||
style={{
|
||||
height: `calc(100% - ${buttonSize()} - 32px)`,
|
||||
'max-height': props.theme?.chatWindow?.maxHeight ?? '704px',
|
||||
'max-width': props.theme?.chatWindow?.maxWidth ?? '400px',
|
||||
transition:
|
||||
'transform 200ms cubic-bezier(0, 1.2, 1, 1), opacity 150ms ease-out',
|
||||
'transform-origin':
|
||||
props.theme?.placement === 'left' ? 'bottom left' : 'bottom right',
|
||||
transform: isBotOpened() ? 'scale3d(1, 1, 1)' : 'scale3d(0, 0, 1)',
|
||||
'box-shadow': 'rgb(0 0 0 / 16%) 0px 5px 40px',
|
||||
'background-color': bubbleProps.theme?.chatWindow?.backgroundColor,
|
||||
'z-index': 42424242,
|
||||
bottom: `calc(${buttonSize()} + 32px)`,
|
||||
}}
|
||||
class={
|
||||
'fixed rounded-lg w-full' +
|
||||
(isBotOpened() ? ' opacity-1' : ' opacity-0 pointer-events-none') +
|
||||
(props.theme?.placement === 'left'
|
||||
? ' left-5'
|
||||
: ' sm:right-5 right-0')
|
||||
}
|
||||
<EnvironmentProvider
|
||||
value={document.querySelector('typebot-bubble')?.shadowRoot as Node}
|
||||
>
|
||||
<Show when={isBotStarted()}>
|
||||
<Bot
|
||||
{...botProps}
|
||||
onChatStatePersisted={handleOnChatStatePersisted}
|
||||
prefilledVariables={prefilledVariables()}
|
||||
class="rounded-lg"
|
||||
progressBarRef={progressBarContainerRef}
|
||||
<style>{styles}</style>
|
||||
<Show when={isPreviewMessageDisplayed()}>
|
||||
<PreviewMessage
|
||||
{...previewMessage()}
|
||||
placement={bubbleProps.theme?.placement}
|
||||
previewMessageTheme={bubbleProps.theme?.previewMessage}
|
||||
buttonSize={buttonSize()}
|
||||
onClick={handlePreviewMessageClick}
|
||||
onCloseClick={hideMessage}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
<BubbleButton
|
||||
{...bubbleProps.theme?.button}
|
||||
placement={bubbleProps.theme?.placement}
|
||||
toggleBot={toggleBot}
|
||||
isBotOpened={isBotOpened()}
|
||||
buttonSize={buttonSize()}
|
||||
/>
|
||||
<div ref={progressBarContainerRef} />
|
||||
<div
|
||||
part="bot"
|
||||
style={{
|
||||
height: `calc(100% - ${buttonSize()} - 32px)`,
|
||||
'max-height': props.theme?.chatWindow?.maxHeight ?? '704px',
|
||||
'max-width': props.theme?.chatWindow?.maxWidth ?? '400px',
|
||||
transition:
|
||||
'transform 200ms cubic-bezier(0, 1.2, 1, 1), opacity 150ms ease-out',
|
||||
'transform-origin':
|
||||
props.theme?.placement === 'left'
|
||||
? 'bottom left'
|
||||
: 'bottom right',
|
||||
transform: isBotOpened() ? 'scale3d(1, 1, 1)' : 'scale3d(0, 0, 1)',
|
||||
'box-shadow': 'rgb(0 0 0 / 16%) 0px 5px 40px',
|
||||
'background-color': bubbleProps.theme?.chatWindow?.backgroundColor,
|
||||
'z-index': 42424242,
|
||||
bottom: `calc(${buttonSize()} + 32px)`,
|
||||
}}
|
||||
class={
|
||||
'fixed rounded-lg w-full' +
|
||||
(isBotOpened() ? ' opacity-1' : ' opacity-0 pointer-events-none') +
|
||||
(props.theme?.placement === 'left'
|
||||
? ' left-5'
|
||||
: ' sm:right-5 right-0')
|
||||
}
|
||||
>
|
||||
<Show when={isBotStarted()}>
|
||||
<Bot
|
||||
{...botProps}
|
||||
onChatStatePersisted={handleOnChatStatePersisted}
|
||||
prefilledVariables={prefilledVariables()}
|
||||
class="rounded-lg"
|
||||
progressBarRef={progressBarContainerRef}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</EnvironmentProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
@ -17,6 +17,7 @@ import {
|
||||
removeBotOpenedStateInStorage,
|
||||
setBotOpenedStateInStorage,
|
||||
} from '@/utils/storage'
|
||||
import { EnvironmentProvider } from '@ark-ui/solid'
|
||||
|
||||
export type PopupProps = BotProps &
|
||||
PopupParams & {
|
||||
@ -118,44 +119,48 @@ export const Popup = (props: PopupProps) => {
|
||||
|
||||
return (
|
||||
<Show when={isBotOpened()}>
|
||||
<style>{styles}</style>
|
||||
<div
|
||||
class="relative"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
'z-index': props.theme?.zIndex ?? 42424242,
|
||||
}}
|
||||
<EnvironmentProvider
|
||||
value={document.querySelector('typebot-popup')?.shadowRoot as Node}
|
||||
>
|
||||
<style>{styles}</style>
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 transition-opacity animate-fade-in"
|
||||
part="overlay"
|
||||
/>
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div
|
||||
class={
|
||||
'relative h-[80vh] transform overflow-hidden rounded-lg text-left transition-all sm:my-8 w-full max-w-lg' +
|
||||
(props.theme?.backgroundColor ? ' shadow-xl' : '')
|
||||
}
|
||||
style={{
|
||||
'background-color':
|
||||
props.theme?.backgroundColor ?? 'transparent',
|
||||
'max-width': props.theme?.width ?? '512px',
|
||||
}}
|
||||
on:pointerdown={stopPropagation}
|
||||
>
|
||||
<Bot
|
||||
{...botProps}
|
||||
prefilledVariables={prefilledVariables()}
|
||||
onChatStatePersisted={handleOnChatStatePersisted}
|
||||
/>
|
||||
class="relative"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
'z-index': props.theme?.zIndex ?? 42424242,
|
||||
}}
|
||||
>
|
||||
<style>{styles}</style>
|
||||
<div
|
||||
class="fixed inset-0 bg-black bg-opacity-50 transition-opacity animate-fade-in"
|
||||
part="overlay"
|
||||
/>
|
||||
<div class="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||
<div
|
||||
class={
|
||||
'relative h-[80vh] transform overflow-hidden rounded-lg text-left transition-all sm:my-8 w-full max-w-lg' +
|
||||
(props.theme?.backgroundColor ? ' shadow-xl' : '')
|
||||
}
|
||||
style={{
|
||||
'background-color':
|
||||
props.theme?.backgroundColor ?? 'transparent',
|
||||
'max-width': props.theme?.width ?? '512px',
|
||||
}}
|
||||
on:pointerdown={stopPropagation}
|
||||
>
|
||||
<Bot
|
||||
{...botProps}
|
||||
prefilledVariables={prefilledVariables()}
|
||||
onChatStatePersisted={handleOnChatStatePersisted}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EnvironmentProvider>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import styles from '../../../assets/index.css'
|
||||
import { Bot, BotProps } from '@/components/Bot'
|
||||
import { CommandData } from '@/features/commands/types'
|
||||
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
|
||||
import { EnvironmentProvider } from '@ark-ui/solid'
|
||||
|
||||
const hostElementCss = `
|
||||
:host {
|
||||
@ -42,7 +43,9 @@ export const Standard = (
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<EnvironmentProvider
|
||||
value={document.querySelector('typebot-standard')?.shadowRoot as Node}
|
||||
>
|
||||
<style>
|
||||
{styles}
|
||||
{hostElementCss}
|
||||
@ -50,6 +53,6 @@ export const Standard = (
|
||||
<Show when={isBotDisplayed()}>
|
||||
<Bot {...props} />
|
||||
</Show>
|
||||
</>
|
||||
</EnvironmentProvider>
|
||||
)
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import { ContinueChatResponse, StartChatResponse } from '@typebot.io/schemas'
|
||||
export type InputSubmitContent = {
|
||||
label?: string
|
||||
value: string
|
||||
attachments?: Answer['attachments']
|
||||
}
|
||||
|
||||
export type BotContext = {
|
||||
@ -36,3 +37,11 @@ export type ChatChunk = Pick<
|
||||
> & {
|
||||
streamingMessageId?: string
|
||||
}
|
||||
|
||||
export type Answer = {
|
||||
text: string
|
||||
attachments?: {
|
||||
type: string
|
||||
url: string
|
||||
}[]
|
||||
}
|
||||
|
6
packages/embeds/js/src/utils/toaster.ts
Normal file
6
packages/embeds/js/src/utils/toaster.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { createToaster } from '@ark-ui/solid'
|
||||
|
||||
export const toaster = createToaster({
|
||||
placement: 'bottom-end',
|
||||
gap: 24,
|
||||
})
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@typebot.io/nextjs",
|
||||
"version": "0.2.92",
|
||||
"version": "0.3.0",
|
||||
"description": "Convenient library to display typebots on your Next.js website",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@typebot.io/react",
|
||||
"version": "0.2.92",
|
||||
"version": "0.3.0",
|
||||
"description": "Convenient library to display typebots on your React app",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
9
packages/env/env.ts
vendored
9
packages/env/env.ts
vendored
@ -341,10 +341,9 @@ const whatsAppEnv = {
|
||||
},
|
||||
}
|
||||
|
||||
const upstashRedis = {
|
||||
const redisEnv = {
|
||||
server: {
|
||||
UPSTASH_REDIS_REST_URL: z.string().url().optional(),
|
||||
UPSTASH_REDIS_REST_TOKEN: z.string().min(1).optional(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
},
|
||||
}
|
||||
|
||||
@ -425,13 +424,13 @@ export const env = createEnv({
|
||||
...vercelEnv.server,
|
||||
...sleekPlanEnv.server,
|
||||
...whatsAppEnv.server,
|
||||
...upstashRedis.server,
|
||||
...redisEnv.server,
|
||||
...gitlabEnv.server,
|
||||
...azureEnv.server,
|
||||
...customOAuthEnv.server,
|
||||
...sentryEnv.server,
|
||||
...telemetryEnv.server,
|
||||
...keycloakEnv.server
|
||||
...keycloakEnv.server,
|
||||
},
|
||||
client: {
|
||||
...baseEnv.client,
|
||||
|
@ -39,6 +39,7 @@
|
||||
"escape-html": "1.0.3",
|
||||
"google-auth-library": "8.9.0",
|
||||
"ky": "1.2.4",
|
||||
"ioredis": "5.4.1",
|
||||
"minio": "7.1.3",
|
||||
"posthog-node": "3.1.1",
|
||||
"remark-parse": "11.0.0",
|
||||
|
16
packages/lib/redis.ts
Normal file
16
packages/lib/redis.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { env } from '@typebot.io/env'
|
||||
import Redis from 'ioredis'
|
||||
|
||||
declare const global: { redis: Redis | undefined }
|
||||
let redis: Redis | undefined
|
||||
|
||||
if (env.NODE_ENV === 'production' && !process.versions.bun && env.REDIS_URL) {
|
||||
redis = new Redis(env.REDIS_URL)
|
||||
} else if (env.REDIS_URL) {
|
||||
if (!global.redis) {
|
||||
global.redis = new Redis(env.REDIS_URL)
|
||||
}
|
||||
redis = global.redis
|
||||
}
|
||||
|
||||
export default redis
|
@ -10,7 +10,6 @@ import {
|
||||
import { SetVariableHistoryItem } from '@typebot.io/schemas/features/result'
|
||||
import { isBubbleBlock, isInputBlock } from '@typebot.io/schemas/helpers'
|
||||
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
|
||||
import { convertRichTextToMarkdown } from '@typebot.io/lib/markdown/convertRichTextToMarkdown'
|
||||
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
|
||||
import { createId } from '@typebot.io/lib/createId'
|
||||
import { executeCondition } from './executeCondition'
|
||||
@ -56,7 +55,7 @@ export const computeResultTranscript = ({
|
||||
stopAtBlockId,
|
||||
}: {
|
||||
typebot: TypebotInSession
|
||||
answers: Pick<Answer, 'blockId' | 'content'>[]
|
||||
answers: Pick<Answer, 'blockId' | 'content' | 'attachedFileUrls'>[]
|
||||
setVariableHistory: Pick<
|
||||
SetVariableHistoryItem,
|
||||
'blockId' | 'variableId' | 'value'
|
||||
@ -120,7 +119,7 @@ const executeGroup = ({
|
||||
typebot: TypebotInSession
|
||||
resumeEdgeId?: string
|
||||
}[]
|
||||
answers: Pick<Answer, 'blockId' | 'content'>[]
|
||||
answers: Pick<Answer, 'blockId' | 'content' | 'attachedFileUrls'>[]
|
||||
setVariableHistory: Pick<
|
||||
SetVariableHistoryItem,
|
||||
'blockId' | 'variableId' | 'value'
|
||||
@ -157,20 +156,50 @@ const executeGroup = ({
|
||||
const answer = answers.shift()
|
||||
if (!answer) break
|
||||
if (block.options?.variableId) {
|
||||
const variable = typebotsQueue[0].typebot.variables.find(
|
||||
const replyVariable = typebotsQueue[0].typebot.variables.find(
|
||||
(variable) => variable.id === block.options?.variableId
|
||||
)
|
||||
if (replyVariable) {
|
||||
typebotsQueue[0].typebot.variables =
|
||||
typebotsQueue[0].typebot.variables.map((v) =>
|
||||
v.id === replyVariable.id ? { ...v, value: answer.content } : v
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
block.type === InputBlockType.TEXT &&
|
||||
block.options?.attachments?.isEnabled &&
|
||||
block.options?.attachments?.saveVariableId &&
|
||||
answer.attachedFileUrls &&
|
||||
answer.attachedFileUrls?.length > 0
|
||||
) {
|
||||
const variable = typebotsQueue[0].typebot.variables.find(
|
||||
(variable) =>
|
||||
variable.id === block.options?.attachments?.saveVariableId
|
||||
)
|
||||
if (variable) {
|
||||
typebotsQueue[0].typebot.variables =
|
||||
typebotsQueue[0].typebot.variables.map((v) =>
|
||||
v.id === variable.id ? { ...v, value: answer.content } : v
|
||||
v.id === variable.id
|
||||
? {
|
||||
...v,
|
||||
value: Array.isArray(variable.value)
|
||||
? variable.value.concat(answer.attachedFileUrls!)
|
||||
: answer.attachedFileUrls!.length === 1
|
||||
? answer.attachedFileUrls![0]
|
||||
: answer.attachedFileUrls,
|
||||
}
|
||||
: v
|
||||
)
|
||||
}
|
||||
}
|
||||
currentTranscript.push({
|
||||
role: 'user',
|
||||
type: 'text',
|
||||
text: answer.content,
|
||||
text:
|
||||
(answer.attachedFileUrls?.length ?? 0) > 0
|
||||
? `${answer.attachedFileUrls?.join(', ')}\n\n${answer.content}`
|
||||
: answer.content,
|
||||
})
|
||||
const outgoingEdge = getOutgoingEdgeId({
|
||||
block,
|
||||
|
@ -320,12 +320,13 @@ model Answer {
|
||||
}
|
||||
|
||||
model AnswerV2 {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
blockId String
|
||||
content String @db.Text
|
||||
resultId String
|
||||
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
blockId String
|
||||
content String @db.Text
|
||||
attachedFileUrls Json?
|
||||
resultId String
|
||||
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([resultId])
|
||||
@@index([blockId])
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "AnswerV2" ADD COLUMN "attachedFileUrls" JSONB;
|
@ -300,12 +300,13 @@ model Answer {
|
||||
}
|
||||
|
||||
model AnswerV2 {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
blockId String
|
||||
content String
|
||||
resultId String
|
||||
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now())
|
||||
blockId String
|
||||
content String
|
||||
attachedFileUrls Json?
|
||||
resultId String
|
||||
result Result @relation(fields: [resultId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([blockId])
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ const answerV1Schema = z.object({
|
||||
export const answerSchema = z.object({
|
||||
blockId: z.string(),
|
||||
content: z.string(),
|
||||
attachedFileUrls: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const answerInputSchema = answerV1Schema
|
||||
|
@ -4,4 +4,8 @@ import { TextInputBlock } from './schema'
|
||||
export const defaultTextInputOptions = {
|
||||
isLong: false,
|
||||
labels: { button: defaultButtonLabel, placeholder: 'Type your answer...' },
|
||||
attachments: {
|
||||
isEnabled: false,
|
||||
visibility: 'Auto',
|
||||
},
|
||||
} as const satisfies TextInputBlock['options']
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { z } from '../../../../zod'
|
||||
import { optionBaseSchema, blockBaseSchema } from '../../shared'
|
||||
import { InputBlockType } from '../constants'
|
||||
import { fileVisibilityOptions } from '../file/constants'
|
||||
|
||||
export const textInputOptionsBaseSchema = z.object({
|
||||
labels: z
|
||||
@ -16,6 +17,13 @@ export const textInputOptionsSchema = textInputOptionsBaseSchema
|
||||
.merge(
|
||||
z.object({
|
||||
isLong: z.boolean().optional(),
|
||||
attachments: z
|
||||
.object({
|
||||
isEnabled: z.boolean().optional(),
|
||||
saveVariableId: z.string().optional(),
|
||||
visibility: z.enum(fileVisibilityOptions).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
)
|
||||
|
||||
|
@ -29,6 +29,23 @@ import { BubbleBlockType } from '../blocks/bubbles/constants'
|
||||
import { clientSideActionSchema } from './clientSideAction'
|
||||
import { ChatSession as ChatSessionFromPrisma } from '@typebot.io/prisma'
|
||||
|
||||
export const messageSchema = z.preprocess(
|
||||
(val) => (typeof val === 'string' ? { type: 'text', text: val } : val),
|
||||
z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('text'),
|
||||
text: z.string(),
|
||||
attachedFileUrls: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
'Can only be provided if current input block is a text input block that allows attachments'
|
||||
),
|
||||
}),
|
||||
])
|
||||
)
|
||||
export type Message = z.infer<typeof messageSchema>
|
||||
|
||||
const chatSessionSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@ -183,8 +200,7 @@ export const startChatInputSchema = z.object({
|
||||
.describe(
|
||||
"[Where to find my bot's public ID?](../how-to#how-to-find-my-publicid)"
|
||||
),
|
||||
message: z
|
||||
.string()
|
||||
message: messageSchema
|
||||
.optional()
|
||||
.describe(
|
||||
"Only provide it if your flow starts with an input block and you'd like to directly provide an answer to it."
|
||||
@ -242,7 +258,7 @@ export const startPreviewChatInputSchema = z.object({
|
||||
"[Where to find my bot's ID?](../how-to#how-to-find-my-typebotid)"
|
||||
),
|
||||
isStreamEnabled: z.boolean().optional().default(false),
|
||||
message: z.string().optional(),
|
||||
message: messageSchema.optional(),
|
||||
isOnlyRegistering: z
|
||||
.boolean()
|
||||
.optional()
|
||||
|
@ -115,13 +115,13 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
from: z.string(),
|
||||
type: z.literal('image'),
|
||||
image: z.object({ id: z.string() }),
|
||||
image: z.object({ id: z.string(), caption: z.string().optional() }),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
from: z.string(),
|
||||
type: z.literal('video'),
|
||||
video: z.object({ id: z.string() }),
|
||||
video: z.object({ id: z.string(), caption: z.string().optional() }),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
@ -133,7 +133,7 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
from: z.string(),
|
||||
type: z.literal('document'),
|
||||
document: z.object({ id: z.string() }),
|
||||
document: z.object({ id: z.string(), caption: z.string().optional() }),
|
||||
timestamp: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
|
Reference in New Issue
Block a user