diff --git a/apps/builder/src/components/DropdownList.tsx b/apps/builder/src/components/DropdownList.tsx index 7655dc03a..9ac5f7b67 100644 --- a/apps/builder/src/components/DropdownList.tsx +++ b/apps/builder/src/components/DropdownList.tsx @@ -14,6 +14,7 @@ import { Stack, chakra, } from "@chakra-ui/react"; +import { useTranslate } from "@tolgee/react"; import type { ReactElement, ReactNode } from "react"; import React from "react"; import { MoreInfoTooltip } from "./MoreInfoTooltip"; @@ -54,6 +55,7 @@ export const DropdownList = ({ moreInfoTooltip, ...props }: Props & ButtonProps) => { + const { t } = useTranslate(); const handleMenuItemClick = (item: T) => () => { if (typeof item === "string" || typeof item === "number") onItemSelect( @@ -109,7 +111,8 @@ export const DropdownList = ({ : currentItem === item.value, ), ) - : (placeholder ?? "Select an item")} + : (placeholder ?? + t("components.dropdownList.defaultPlaceholder"))} diff --git a/apps/builder/src/features/blocks/inputs/number/components/NumberInputSettings.tsx b/apps/builder/src/features/blocks/inputs/number/components/NumberInputSettings.tsx index b803f39a3..1603ed3da 100644 --- a/apps/builder/src/features/blocks/inputs/number/components/NumberInputSettings.tsx +++ b/apps/builder/src/features/blocks/inputs/number/components/NumberInputSettings.tsx @@ -1,11 +1,36 @@ +import { DropdownList } from "@/components/DropdownList"; import { NumberInput, TextInput } from "@/components/inputs"; +import { Select } from "@/components/inputs/Select"; import { VariableSearchInput } from "@/components/inputs/VariableSearchInput"; -import { FormLabel, Stack } from "@chakra-ui/react"; +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + FormControl, + FormLabel, + Stack, + Text, +} from "@chakra-ui/react"; import { useTranslate } from "@tolgee/react"; -import { defaultNumberInputOptions } from "@typebot.io/blocks-inputs/number/constants"; -import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema"; +import { + NumberInputStyle, + NumberInputUnit, + defaultNumberInputButtonLabel, + defaultNumberInputPlaceholder, + defaultNumberInputStyle, + localeRegex, + numberStyleTranslationKeys, + unitTranslationKeys, +} from "@typebot.io/blocks-inputs/number/constants"; +import { + type NumberInputBlock, + numberInputOptionsSchema, +} from "@typebot.io/blocks-inputs/number/schema"; import type { Variable } from "@typebot.io/variables/schemas"; -import React from "react"; +import React, { useEffect } from "react"; +import { currencies } from "../../payment/currencies"; type Props = { options: NumberInputBlock["options"]; @@ -14,6 +39,16 @@ type Props = { export const NumberInputSettings = ({ options, onOptionsChange }: Props) => { const { t } = useTranslate(); + + useEffect(() => { + if (!options?.locale) { + const browserLocale = navigator.language; + if (browserLocale.match(localeRegex)) { + onOptionsChange({ ...options, locale: browserLocale }); + } + } + }); + const handlePlaceholderChange = (placeholder: string) => onOptionsChange({ ...options, @@ -21,6 +56,11 @@ export const NumberInputSettings = ({ options, onOptionsChange }: Props) => { }); const handleButtonLabelChange = (button: string) => onOptionsChange({ ...options, labels: { ...options?.labels, button } }); + const handleCurrencyChange = (currency: string) => + onOptionsChange({ + ...options, + currency, + }); const handleMinChange = ( min?: NonNullable["min"], ) => onOptionsChange({ ...options, min }); @@ -30,6 +70,21 @@ export const NumberInputSettings = ({ options, onOptionsChange }: Props) => { const handleStepChange = ( step?: NonNullable["step"], ) => onOptionsChange({ ...options, step }); + const handleStyleChange = (style: NumberInputStyle) => + onOptionsChange({ + ...options, + style, + }); + const handleUnitChange = (unit: NumberInputUnit) => + onOptionsChange({ ...options, unit }); + const handleLocaleChange = (locale: string) => { + const savableLocale = numberInputOptionsSchema.shape.locale.safeParse( + locale, + ).success + ? locale + : (options?.locale ?? navigator.language); + onOptionsChange({ ...options, locale: savableLocale }); + }; const handleVariableChange = (variable?: Variable) => { onOptionsChange({ ...options, variableId: variable?.id }); }; @@ -39,16 +94,13 @@ export const NumberInputSettings = ({ options, onOptionsChange }: Props) => { { defaultValue={options?.step} onValueChange={handleStepChange} /> + + + + + {t("blocks.inputs.number.settings.format.label")} + + + + + ({ + label: t(numberStyleTranslationKeys[style]), + value: style, + }))} + currentItem={options?.style ?? defaultNumberInputStyle} + onItemSelect={(value) => + handleStyleChange(value as NumberInputStyle) + } + /> + {options?.style === NumberInputStyle.CURRENCY && ( + + + {t("blocks.inputs.number.settings.currency.label")} + + ({ + label: t(unitTranslationKeys[unit]), + value: unit, + }))} + onSelect={(value) => + handleUnitChange(value as NumberInputUnit) + } + placeholder={t("blocks.inputs.number.settings.unit.label")} + selectedItem={options?.unit} + /> + + )} + + + {t("blocks.inputs.number.settings.locale.label")} + + + + + + {t("blocks.inputs.settings.saveAnswer.label")} diff --git a/apps/builder/src/features/blocks/inputs/number/components/NumberNodeContent.tsx b/apps/builder/src/features/blocks/inputs/number/components/NumberNodeContent.tsx index c5ea72e6d..024c5f96d 100644 --- a/apps/builder/src/features/blocks/inputs/number/components/NumberNodeContent.tsx +++ b/apps/builder/src/features/blocks/inputs/number/components/NumberNodeContent.tsx @@ -1,6 +1,6 @@ import { WithVariableContent } from "@/features/graph/components/nodes/block/WithVariableContent"; import { Text } from "@chakra-ui/react"; -import { defaultNumberInputOptions } from "@typebot.io/blocks-inputs/number/constants"; +import { defaultNumberInputPlaceholder } from "@typebot.io/blocks-inputs/number/constants"; import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema"; import React from "react"; @@ -15,6 +15,6 @@ export const NumberNodeContent = ({ ) : ( - {labels?.placeholder ?? defaultNumberInputOptions.labels.placeholder} + {labels?.placeholder ?? defaultNumberInputPlaceholder} ); diff --git a/apps/builder/src/features/blocks/inputs/number/number.spec.ts b/apps/builder/src/features/blocks/inputs/number/number.spec.ts index 4b04d8202..1ee19b24b 100644 --- a/apps/builder/src/features/blocks/inputs/number/number.spec.ts +++ b/apps/builder/src/features/blocks/inputs/number/number.spec.ts @@ -1,7 +1,7 @@ import { createId } from "@paralleldrive/cuid2"; import test, { expect } from "@playwright/test"; import { InputBlockType } from "@typebot.io/blocks-inputs/constants"; -import { defaultNumberInputOptions } from "@typebot.io/blocks-inputs/number/constants"; +import { defaultNumberInputPlaceholder } from "@typebot.io/blocks-inputs/number/constants"; import { createTypebots } from "@typebot.io/playwright/databaseActions"; import { parseDefaultGroupWithBlock } from "@typebot.io/playwright/databaseHelpers"; @@ -21,12 +21,10 @@ test.describe("Number input block", () => { await page.click("text=Test"); await expect( - page.locator( - `input[placeholder="${defaultNumberInputOptions.labels.placeholder}"]`, - ), + page.locator(`input[placeholder="${defaultNumberInputPlaceholder}"]`), ).toHaveAttribute("type", "number"); - await page.click(`text=${defaultNumberInputOptions.labels.placeholder}`); + await page.click(`text=${defaultNumberInputPlaceholder}`); await page.getByLabel("Placeholder:").fill("Your number..."); await expect(page.locator("text=Your number...")).toBeVisible(); await page.getByLabel("Button label:").fill("Go"); diff --git a/apps/builder/src/features/blocks/inputs/payment/currencies.tsx b/apps/builder/src/features/blocks/inputs/payment/currencies.tsx index 001c42650..6344f3864 100644 --- a/apps/builder/src/features/blocks/inputs/payment/currencies.tsx +++ b/apps/builder/src/features/blocks/inputs/payment/currencies.tsx @@ -1,7 +1,12 @@ // The STRIPE-supported currencies, sorted by code // https://gist.github.com/chrisdavies/9e3f00889fb764013339632bd3f2a71b -export const currencies = [ +export type Currency = { + code: string; + description: string; +}; + +export const currencies: Currency[] = [ { code: "AED", description: "United Arab Emirates Dirham", diff --git a/apps/builder/src/i18n/en.json b/apps/builder/src/i18n/en.json index 531cbf448..f6d92577c 100644 --- a/apps/builder/src/i18n/en.json +++ b/apps/builder/src/i18n/en.json @@ -147,7 +147,61 @@ "blocks.inputs.file.settings.saveSingleUpload.label": "Save upload URL in a variable:", "blocks.inputs.file.settings.skip.label": "Skip button label:", "blocks.inputs.fileUpload.blockCard.tooltip": "Upload Files", + "blocks.inputs.number.settings.currency.label": "Currency:", + "blocks.inputs.number.settings.format.label": "Format", + "blocks.inputs.number.settings.locale.label": "Locale:", + "blocks.inputs.number.settings.locale.helper": "Locale codes follow the format 'en' or 'en-US' (language-region)", "blocks.inputs.number.settings.step.label": "Step:", + "blocks.inputs.number.settings.unit.label": "Unit:", + "blocks.inputs.number.style.decimal": "Decimal", + "blocks.inputs.number.style.currency": "Currency", + "blocks.inputs.number.style.percent": "Percent", + "blocks.inputs.number.style.unit": "Unit", + "blocks.inputs.number.unit.acre": "acre", + "blocks.inputs.number.unit.bit": "bit", + "blocks.inputs.number.unit.byte": "byte", + "blocks.inputs.number.unit.celsius": "celsius", + "blocks.inputs.number.unit.centimeter": "centimeter", + "blocks.inputs.number.unit.day": "day", + "blocks.inputs.number.unit.degree": "degree", + "blocks.inputs.number.unit.fahrenheit": "fahrenheit", + "blocks.inputs.number.unit.fluidOunce": "fluid ounce", + "blocks.inputs.number.unit.foot": "foot", + "blocks.inputs.number.unit.gallon": "gallon", + "blocks.inputs.number.unit.gigabit": "gigabit", + "blocks.inputs.number.unit.gigabyte": "gigabyte", + "blocks.inputs.number.unit.gram": "gram", + "blocks.inputs.number.unit.hectare": "hectare", + "blocks.inputs.number.unit.hour": "hour", + "blocks.inputs.number.unit.inch": "inch", + "blocks.inputs.number.unit.kilobit": "kilobit", + "blocks.inputs.number.unit.kilobyte": "kilobyte", + "blocks.inputs.number.unit.kilogram": "kilogram", + "blocks.inputs.number.unit.kilometer": "kilometer", + "blocks.inputs.number.unit.liter": "liter", + "blocks.inputs.number.unit.megabit": "megabit", + "blocks.inputs.number.unit.megabyte": "megabyte", + "blocks.inputs.number.unit.meter": "meter", + "blocks.inputs.number.unit.microsecond": "microsecond", + "blocks.inputs.number.unit.mile": "mile", + "blocks.inputs.number.unit.mileScandinavian": "Scandinavian mile", + "blocks.inputs.number.unit.milliliter": "milliliter", + "blocks.inputs.number.unit.millimeter": "millimeter", + "blocks.inputs.number.unit.millisecond": "millisecond", + "blocks.inputs.number.unit.minute": "minute", + "blocks.inputs.number.unit.month": "month", + "blocks.inputs.number.unit.nanosecond": "nanosecond", + "blocks.inputs.number.unit.ounce": "ounce", + "blocks.inputs.number.unit.percent": "percent", + "blocks.inputs.number.unit.petabyte": "petabyte", + "blocks.inputs.number.unit.pound": "pound", + "blocks.inputs.number.unit.second": "second", + "blocks.inputs.number.unit.stone": "stone", + "blocks.inputs.number.unit.terabit": "terabit", + "blocks.inputs.number.unit.terabyte": "terabyte", + "blocks.inputs.number.unit.week": "week", + "blocks.inputs.number.unit.yard": "yard", + "blocks.inputs.number.unit.year": "year", "blocks.inputs.payment.collect.label": "Collect", "blocks.inputs.payment.placeholder.label": "Configure...", "blocks.inputs.payment.settings.account.label": "Account:", @@ -224,6 +278,7 @@ "colorPicker.advancedColors": "Advanced colors", "colorPicker.colorValue.ariaLabel": "Color value", "colorPicker.pickColor.ariaLabel": "Pick a color", + "components.dropdownList.defaultPlaceholder": "Select an item", "confirmModal.defaultTitle": "Are you sure?", "connect": "Connect", "connectNew": "Connect new", diff --git a/packages/blocks/inputs/src/number/constants.ts b/packages/blocks/inputs/src/number/constants.ts index 04c20f047..6daf61196 100644 --- a/packages/blocks/inputs/src/number/constants.ts +++ b/packages/blocks/inputs/src/number/constants.ts @@ -1,6 +1,120 @@ import { defaultButtonLabel } from "../constants"; import type { NumberInputBlock } from "./schema"; -export const defaultNumberInputOptions = { - labels: { button: defaultButtonLabel, placeholder: "Type a number..." }, -} as const satisfies NumberInputBlock["options"]; +export enum NumberInputStyle { + DECIMAL = "decimal", + CURRENCY = "currency", + PERCENT = "percent", + UNIT = "unit", +} + +export const localeRegex = /^[a-z]{2}(-[A-Z]{2})?$/; + +export const numberStyleTranslationKeys: Record = { + [NumberInputStyle.DECIMAL]: "blocks.inputs.number.style.decimal", + [NumberInputStyle.CURRENCY]: "blocks.inputs.number.style.currency", + [NumberInputStyle.PERCENT]: "blocks.inputs.number.style.percent", + [NumberInputStyle.UNIT]: "blocks.inputs.number.style.unit", +}; + +export enum NumberInputUnit { + ACRE = "acre", + BIT = "bit", + BYTE = "byte", + CELSIUS = "celsius", + CENTIMETER = "centimeter", + DAY = "day", + DEGREE = "degree", + FAHRENHEIT = "fahrenheit", + FLUID_OUNCE = "fluid-ounce", + FOOT = "foot", + GALLON = "gallon", + GIGABIT = "gigabit", + GIGABYTE = "gigabyte", + GRAM = "gram", + HECTARE = "hectare", + HOUR = "hour", + INCH = "inch", + KILOBIT = "kilobit", + KILOBYTE = "kilobyte", + KILOGRAM = "kilogram", + KILOMETER = "kilometer", + LITER = "liter", + MEGABIT = "megabit", + MEGABYTE = "megabyte", + METER = "meter", + MICROSECOND = "microsecond", + MILE = "mile", + MILE_SCANDINAVIAN = "mile-scandinavian", + MILLILITER = "milliliter", + MILLIMETER = "millimeter", + MILLISECOND = "millisecond", + MINUTE = "minute", + MONTH = "month", + NANOSECOND = "nanosecond", + OUNCE = "ounce", + PERCENT = "percent", + PETABYTE = "petabyte", + POUND = "pound", + SECOND = "second", + STONE = "stone", + TERABIT = "terabit", + TERABYTE = "terabyte", + WEEK = "week", + YARD = "yard", + YEAR = "year", +} + +export const defaultNumberInputStyle = NumberInputStyle.DECIMAL; +export const defaultNumberInputButtonLabel = defaultButtonLabel; +export const defaultNumberInputPlaceholder = "Type a number..."; + +// Map unit types to translation keys +export const unitTranslationKeys: Record = { + [NumberInputUnit.ACRE]: "blocks.inputs.number.unit.acre", + [NumberInputUnit.BIT]: "blocks.inputs.number.unit.bit", + [NumberInputUnit.BYTE]: "blocks.inputs.number.unit.byte", + [NumberInputUnit.CELSIUS]: "blocks.inputs.number.unit.celsius", + [NumberInputUnit.CENTIMETER]: "blocks.inputs.number.unit.centimeter", + [NumberInputUnit.DAY]: "blocks.inputs.number.unit.day", + [NumberInputUnit.DEGREE]: "blocks.inputs.number.unit.degree", + [NumberInputUnit.FAHRENHEIT]: "blocks.inputs.number.unit.fahrenheit", + [NumberInputUnit.FLUID_OUNCE]: "blocks.inputs.number.unit.fluidOunce", + [NumberInputUnit.FOOT]: "blocks.inputs.number.unit.foot", + [NumberInputUnit.GALLON]: "blocks.inputs.number.unit.gallon", + [NumberInputUnit.GIGABIT]: "blocks.inputs.number.unit.gigabit", + [NumberInputUnit.GIGABYTE]: "blocks.inputs.number.unit.gigabyte", + [NumberInputUnit.GRAM]: "blocks.inputs.number.unit.gram", + [NumberInputUnit.HECTARE]: "blocks.inputs.number.unit.hectare", + [NumberInputUnit.HOUR]: "blocks.inputs.number.unit.hour", + [NumberInputUnit.INCH]: "blocks.inputs.number.unit.inch", + [NumberInputUnit.KILOBIT]: "blocks.inputs.number.unit.kilobit", + [NumberInputUnit.KILOBYTE]: "blocks.inputs.number.unit.kilobyte", + [NumberInputUnit.KILOGRAM]: "blocks.inputs.number.unit.kilogram", + [NumberInputUnit.KILOMETER]: "blocks.inputs.number.unit.kilometer", + [NumberInputUnit.LITER]: "blocks.inputs.number.unit.liter", + [NumberInputUnit.MEGABIT]: "blocks.inputs.number.unit.megabit", + [NumberInputUnit.MEGABYTE]: "blocks.inputs.number.unit.megabyte", + [NumberInputUnit.METER]: "blocks.inputs.number.unit.meter", + [NumberInputUnit.MICROSECOND]: "blocks.inputs.number.unit.microsecond", + [NumberInputUnit.MILE]: "blocks.inputs.number.unit.mile", + [NumberInputUnit.MILE_SCANDINAVIAN]: + "blocks.inputs.number.unit.mileScandinavian", + [NumberInputUnit.MILLILITER]: "blocks.inputs.number.unit.milliliter", + [NumberInputUnit.MILLIMETER]: "blocks.inputs.number.unit.millimeter", + [NumberInputUnit.MILLISECOND]: "blocks.inputs.number.unit.millisecond", + [NumberInputUnit.MINUTE]: "blocks.inputs.number.unit.minute", + [NumberInputUnit.MONTH]: "blocks.inputs.number.unit.month", + [NumberInputUnit.NANOSECOND]: "blocks.inputs.number.unit.nanosecond", + [NumberInputUnit.OUNCE]: "blocks.inputs.number.unit.ounce", + [NumberInputUnit.PERCENT]: "blocks.inputs.number.unit.percent", + [NumberInputUnit.PETABYTE]: "blocks.inputs.number.unit.petabyte", + [NumberInputUnit.POUND]: "blocks.inputs.number.unit.pound", + [NumberInputUnit.SECOND]: "blocks.inputs.number.unit.second", + [NumberInputUnit.STONE]: "blocks.inputs.number.unit.stone", + [NumberInputUnit.TERABIT]: "blocks.inputs.number.unit.terabit", + [NumberInputUnit.TERABYTE]: "blocks.inputs.number.unit.terabyte", + [NumberInputUnit.WEEK]: "blocks.inputs.number.unit.week", + [NumberInputUnit.YARD]: "blocks.inputs.number.unit.yard", + [NumberInputUnit.YEAR]: "blocks.inputs.number.unit.year", +}; diff --git a/packages/blocks/inputs/src/number/schema.ts b/packages/blocks/inputs/src/number/schema.ts index 0d5eb19d5..1b22753f2 100644 --- a/packages/blocks/inputs/src/number/schema.ts +++ b/packages/blocks/inputs/src/number/schema.ts @@ -6,6 +6,7 @@ import { singleVariableOrNumberSchema } from "@typebot.io/variables/schemas"; import { z } from "@typebot.io/zod"; import { InputBlockType } from "../constants"; import { textInputOptionsBaseSchema } from "../text/schema"; +import { NumberInputStyle, NumberInputUnit, localeRegex } from "./constants"; export const numberInputOptionsSchema = optionBaseSchema .merge(textInputOptionsBaseSchema) @@ -14,6 +15,15 @@ export const numberInputOptionsSchema = optionBaseSchema min: singleVariableOrNumberSchema.optional(), max: singleVariableOrNumberSchema.optional(), step: singleVariableOrNumberSchema.optional(), + locale: z + .string() + .regex(localeRegex, { + message: "Invalid locale format. Expected format: 'en' or 'en-US'", + }) + .optional(), + style: z.nativeEnum(NumberInputStyle).optional(), + currency: z.string().optional(), + unit: z.nativeEnum(NumberInputUnit).optional(), }), ); diff --git a/packages/bot-engine/src/blocks/inputs/number/parseFormatOptions.ts b/packages/bot-engine/src/blocks/inputs/number/parseFormatOptions.ts new file mode 100644 index 000000000..6cd1d994a --- /dev/null +++ b/packages/bot-engine/src/blocks/inputs/number/parseFormatOptions.ts @@ -0,0 +1,24 @@ +import { + NumberInputStyle, + defaultNumberInputStyle, +} from "@typebot.io/blocks-inputs/number/constants"; +import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema"; + +export const parseFormatOptions = ( + options: NumberInputBlock["options"], +): Intl.NumberFormatOptions => { + const defaultFormat = { + style: defaultNumberInputStyle, + }; + if (options?.style === NumberInputStyle.CURRENCY && options.currency) + return { + style: options.style, + currency: options.currency, + }; + if (options?.style === NumberInputStyle.UNIT && options.unit) + return { + style: options.style, + unit: options.unit, + }; + return defaultFormat; +}; diff --git a/packages/bot-engine/src/blocks/inputs/number/parseNumber.ts b/packages/bot-engine/src/blocks/inputs/number/parseNumber.ts index a15dfa456..16e267f45 100644 --- a/packages/bot-engine/src/blocks/inputs/number/parseNumber.ts +++ b/packages/bot-engine/src/blocks/inputs/number/parseNumber.ts @@ -1,4 +1,47 @@ -export const parseNumber = (value: string) => { - if (value.startsWith("0")) return value; - return Number.parseFloat(value).toString(); +import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema"; +import { safeParseFloat } from "@typebot.io/lib/safeParseFloat"; +import type { SessionStore } from "@typebot.io/runtime-session-store"; +import { parseVariables } from "@typebot.io/variables/parseVariables"; +import type { Variable } from "@typebot.io/variables/schemas"; +import type { ParsedReply } from "../../../types"; +import { parseFormatOptions } from "./parseFormatOptions"; + +export const parseNumber = ( + inputValue: string, + { + options, + variables, + sessionStore, + }: { + options?: NumberInputBlock["options"]; + variables: Variable[]; + sessionStore: SessionStore; + }, +): ParsedReply => { + if (inputValue === "") return { status: "fail" }; + + const inputValueAsNumber = safeParseFloat(inputValue); + if (!inputValueAsNumber) return { status: "fail" }; + + const min = safeParseFloat( + parseVariables(options?.min?.toString(), { variables, sessionStore }), + ); + const max = safeParseFloat( + parseVariables(options?.max?.toString(), { variables, sessionStore }), + ); + + if (min && inputValueAsNumber < min) return { status: "fail" }; + if (max && inputValueAsNumber > max) return { status: "fail" }; + + // Edge case, return the inputValue as is if starting with 0 + if (inputValue.startsWith("0")) + return { status: "success", content: inputValue }; + + return { + status: "success", + content: Intl.NumberFormat( + options?.locale, + parseFormatOptions(options), + ).format(inputValueAsNumber), + }; }; diff --git a/packages/bot-engine/src/blocks/inputs/number/validateNumber.ts b/packages/bot-engine/src/blocks/inputs/number/validateNumber.ts deleted file mode 100644 index a3e1c3cbf..000000000 --- a/packages/bot-engine/src/blocks/inputs/number/validateNumber.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema"; -import { isNotDefined } from "@typebot.io/lib/utils"; -import type { SessionStore } from "@typebot.io/runtime-session-store"; -import { parseVariables } from "@typebot.io/variables/parseVariables"; -import type { Variable } from "@typebot.io/variables/schemas"; - -export const validateNumber = ( - inputValue: string, - { - options, - variables, - sessionStore, - }: { - options: NumberInputBlock["options"]; - variables: Variable[]; - sessionStore: SessionStore; - }, -) => { - if (inputValue === "") return false; - - const parsedNumber = Number( - inputValue.includes(",") ? inputValue.replace(",", ".") : inputValue, - ); - if (isNaN(parsedNumber)) return false; - - const min = - options?.min && typeof options.min === "string" - ? Number(parseVariables(options.min, { variables, sessionStore })) - : undefined; - const max = - options?.max && typeof options.max === "string" - ? Number(parseVariables(options.max, { variables, sessionStore })) - : undefined; - return ( - (isNotDefined(min) || parsedNumber >= min) && - (isNotDefined(max) || parsedNumber <= max) - ); -}; diff --git a/packages/bot-engine/src/continueBotFlow.ts b/packages/bot-engine/src/continueBotFlow.ts index a05e9bf1c..38fc0b0c1 100644 --- a/packages/bot-engine/src/continueBotFlow.ts +++ b/packages/bot-engine/src/continueBotFlow.ts @@ -40,7 +40,6 @@ import { parseButtonsReply } from "./blocks/inputs/buttons/parseButtonsReply"; import { parseDateReply } from "./blocks/inputs/date/parseDateReply"; import { formatEmail } from "./blocks/inputs/email/formatEmail"; import { parseNumber } from "./blocks/inputs/number/parseNumber"; -import { validateNumber } from "./blocks/inputs/number/validateNumber"; import { formatPhoneNumber } from "./blocks/inputs/phone/formatPhoneNumber"; import { parsePictureChoicesReply } from "./blocks/inputs/pictureChoice/parsePictureChoicesReply"; import { validateRatingReply } from "./blocks/inputs/rating/validateRatingReply"; @@ -760,13 +759,11 @@ const parseReply = async ( } case InputBlockType.NUMBER: { if (!reply || reply.type !== "text") return { status: "fail" }; - const isValid = validateNumber(reply.text, { + return parseNumber(reply.text, { options: block.options, variables: state.typebotsQueue[0].typebot.variables, sessionStore, }); - if (!isValid) return { status: "fail" }; - return { status: "success", content: parseNumber(reply.text) }; } case InputBlockType.DATE: { if (!reply || reply.type !== "text") return { status: "fail" }; diff --git a/packages/embeds/js/src/components/icons/ChevronUpIcon.tsx b/packages/embeds/js/src/components/icons/ChevronUpIcon.tsx new file mode 100644 index 000000000..28f792c9a --- /dev/null +++ b/packages/embeds/js/src/components/icons/ChevronUpIcon.tsx @@ -0,0 +1,16 @@ +import type { JSX } from "solid-js/jsx-runtime"; + +export const ChevronUpIcon = (props: JSX.SvgSVGAttributes) => ( + + + +); diff --git a/packages/embeds/js/src/features/blocks/inputs/number/components/NumberInput.tsx b/packages/embeds/js/src/features/blocks/inputs/number/components/NumberInput.tsx index f52acefdc..823e0681c 100644 --- a/packages/embeds/js/src/features/blocks/inputs/number/components/NumberInput.tsx +++ b/packages/embeds/js/src/features/blocks/inputs/number/components/NumberInput.tsx @@ -1,11 +1,18 @@ import { SendButton } from "@/components/SendButton"; +import { ChevronDownIcon } from "@/components/icons/ChevronDownIcon"; +import { ChevronUpIcon } from "@/components/icons/ChevronUpIcon"; import type { CommandData } from "@/features/commands/types"; import type { InputSubmitContent } from "@/types"; import { isMobile } from "@/utils/isMobileSignal"; -import { defaultNumberInputOptions } from "@typebot.io/blocks-inputs/number/constants"; +import { NumberInput as ArkNumberInput, useNumberInput } from "@ark-ui/solid"; +import { + defaultNumberInputButtonLabel, + defaultNumberInputPlaceholder, +} from "@typebot.io/blocks-inputs/number/constants"; import type { NumberInputBlock } from "@typebot.io/blocks-inputs/number/schema"; -import { createSignal, onCleanup, onMount } from "solid-js"; -import { numberInputHelper } from "../numberInputHelper"; +import { parseFormatOptions } from "@typebot.io/bot-engine/blocks/inputs/number/parseFormatOptions"; +import { safeParseFloat } from "@typebot.io/lib/safeParseFloat"; +import { onCleanup, onMount } from "solid-js"; type NumberInputProps = { block: NumberInputBlock; @@ -14,24 +21,30 @@ type NumberInputProps = { }; export const NumberInput = (props: NumberInputProps) => { - const [inputValue, setInputValue] = createSignal( - props.defaultValue ?? "", - ); - const [staticValue, bindValue, targetValue] = numberInputHelper(() => - inputValue(), - ); + const numberInput = useNumberInput({ + locale: props.block.options?.locale, + formatOptions: parseFormatOptions(props.block.options), + min: safeParseFloat(props.block.options?.min), + max: safeParseFloat(props.block.options?.max), + step: safeParseFloat(props.block.options?.step), + }); let inputRef: HTMLInputElement | undefined; - const checkIfInputIsValid = () => - inputRef?.value !== "" && inputRef?.reportValidity(); + const isInputValid = () => { + if (numberInput().invalid) { + inputRef?.reportValidity(); + return false; + } + return true; + }; const submit = () => { - if (checkIfInputIsValid()) + if (isInputValid()) { props.onSubmit({ type: "text", - value: inputRef?.value ?? inputValue().toString(), + value: numberInput().valueAsNumber.toString(), }); - else inputRef?.focus(); + } else numberInput().focus(); }; const submitWhenEnter = (e: KeyboardEvent) => { @@ -50,7 +63,8 @@ export const NumberInput = (props: NumberInputProps) => { const processIncomingEvent = (event: MessageEvent) => { const { data } = event; if (!data.isFromTypebot) return; - if (data.command === "setInputValue") setInputValue(data.value); + if (data.command === "setInputValue") + numberInput().setValue(Number(data.value)); }; return ( @@ -58,30 +72,30 @@ export const NumberInput = (props: NumberInputProps) => { class="typebot-input-form flex w-full gap-2 items-end max-w-[350px]" onKeyDown={submitWhenEnter} > -
- + { - setInputValue(targetValue(e.currentTarget)); - }} - type="number" - min={props.block.options?.min} - max={props.block.options?.max} - step={props.block.options?.step ?? "any"} /> -
+ + + + + + + + + - {props.block.options?.labels?.button} + {props.block.options?.labels?.button ?? defaultNumberInputButtonLabel} ); diff --git a/packages/embeds/js/tailwind.config.js b/packages/embeds/js/tailwind.config.js index 87d2786fd..3e456087a 100644 --- a/packages/embeds/js/tailwind.config.js +++ b/packages/embeds/js/tailwind.config.js @@ -52,6 +52,8 @@ const config = { "rgba(var(--typebot-host-bubble-bg-rgb), var(--typebot-host-bubble-opacity));", "host-bubble-border": "rgba(var(--typebot-host-bubble-border-rgb), var(--typebot-host-bubble-border-opacity));", + "input-border": + "rgba(var(--typebot-input-border-rgb), var(--typebot-input-border-opacity));", }, extend: { blur: { diff --git a/packages/lib/src/safeParseFloat.ts b/packages/lib/src/safeParseFloat.ts index 31238c750..eeb6e782f 100644 --- a/packages/lib/src/safeParseFloat.ts +++ b/packages/lib/src/safeParseFloat.ts @@ -1,4 +1,6 @@ -export const safeParseFloat = (value: string) => { - const parsedValue = Number.parseFloat(value); +export const safeParseFloat = (value: string | number | undefined) => { + if (typeof value === "number") return value; + if (!value) return undefined; + const parsedValue = Number.parseFloat(value.toString().replace(",", ".")); return isNaN(parsedValue) ? undefined : parsedValue; };