From c5d837a06ffe1ab2e058069a662a2666b9e97525 Mon Sep 17 00:00:00 2001 From: Antoine Lelong Date: Thu, 28 Nov 2024 11:15:29 +0100 Subject: [PATCH 1/5] feat: begin form builder plugin implementation, plug textarea and number / ladder fields --- webapp/CHANGELOG.md | 3 +- webapp/package.json | 1 + webapp/src/components/forms/payload/Form.tsx | 200 ++++++++++++++++++ .../components/forms/payload/Ladder/index.tsx | 85 ++++++++ .../forms/payload/Textarea/index.tsx | 28 +++ .../forms/payload/buildInitialFormState.tsx | 43 ++++ .../src/components/forms/payload/fields.tsx | 7 + webapp/src/pages/dashboard/test-form.tsx | 20 ++ webapp/src/payload/payload-form-builder.ts | 40 ++++ webapp/src/payload/payload-types.ts | 155 +++++++++++++- webapp/src/payload/payload.config.ts | 3 + webapp/src/server/api/root.ts | 2 + webapp/src/server/api/routers/form.ts | 64 ++++++ webapp/src/utils/chakra-theme.ts | 11 + webapp/yarn.lock | 18 +- 15 files changed, 675 insertions(+), 5 deletions(-) create mode 100644 webapp/src/components/forms/payload/Form.tsx create mode 100644 webapp/src/components/forms/payload/Ladder/index.tsx create mode 100644 webapp/src/components/forms/payload/Textarea/index.tsx create mode 100644 webapp/src/components/forms/payload/buildInitialFormState.tsx create mode 100644 webapp/src/components/forms/payload/fields.tsx create mode 100644 webapp/src/pages/dashboard/test-form.tsx create mode 100644 webapp/src/payload/payload-form-builder.ts create mode 100644 webapp/src/server/api/routers/form.ts diff --git a/webapp/CHANGELOG.md b/webapp/CHANGELOG.md index 86c035fa..c3636b10 100644 --- a/webapp/CHANGELOG.md +++ b/webapp/CHANGELOG.md @@ -1,9 +1,8 @@ ## [0.70.26](https://github.com/SocialGouv/carte-jeune-engage/compare/v0.70.25...v0.70.26) (2024-11-25) - ### Bug Fixes -* wallet history button disappear when wallet is empty ([f0477c4](https://github.com/SocialGouv/carte-jeune-engage/commit/f0477c44279b5ddb5d85deff09d5eb095cf97574)) +- wallet history button disappear when wallet is empty ([f0477c4](https://github.com/SocialGouv/carte-jeune-engage/commit/f0477c44279b5ddb5d85deff09d5eb095cf97574)) ## [0.70.25](https://github.com/SocialGouv/carte-jeune-engage/compare/v0.70.24...v0.70.25) (2024-11-22) diff --git a/webapp/package.json b/webapp/package.json index 71625d35..6acaf0d1 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -29,6 +29,7 @@ "@payloadcms/db-postgres": "^0.7.0", "@payloadcms/next-payload": "^0.1.11", "@payloadcms/plugin-cloud-storage": "^1.1.1", + "@payloadcms/plugin-form-builder": "1.2.1", "@payloadcms/richtext-slate": "^1.3.1", "@sentry/nextjs": "^7.100.1", "@serwist/next": "^9.0.2", diff --git a/webapp/src/components/forms/payload/Form.tsx b/webapp/src/components/forms/payload/Form.tsx new file mode 100644 index 00000000..15cfc3e2 --- /dev/null +++ b/webapp/src/components/forms/payload/Form.tsx @@ -0,0 +1,200 @@ +import React, { useState, useCallback } from "react"; +import { buildInitialFormState } from "./buildInitialFormState"; +import { fields } from "./fields"; +import { Form as FormType } from "@payloadcms/plugin-form-builder/dist/types"; +import { useForm } from "react-hook-form"; +import { useRouter } from "next/router"; +import { Box, ButtonGroup, Icon, IconButton } from "@chakra-ui/react"; +import { api } from "~/utils/api"; +import { HiArrowLeft, HiArrowRight } from "react-icons/hi2"; + +export type Value = unknown; + +export interface Property { + [key: string]: Value; +} + +export interface Data { + [key: string]: Value | Property | Property[]; +} + +export type FormBlockType = { + blockName?: string; + blockType?: "formBlock"; + enableIntro: Boolean; + form: FormType; +}; + +const FormField = ({ + formFromProps, + register, + errors, + control, + formMethods, + field, +}: any) => { + const Field: React.FC = fields?.[field.blockType as keyof typeof fields]; + if (Field) { + return ( + + + + ); + } + return null; +}; + +export const FormBlock: React.FC< + FormBlockType & { + id?: string; + } +> = (props) => { + const { + enableIntro, + form: formFromProps, + form: { + id: formID, + submitButtonLabel, + confirmationType, + redirect, + confirmationMessage, + } = {}, + } = props; + + const formMethods = useForm({ + defaultValues: buildInitialFormState(formFromProps.fields), + }); + const { + register, + handleSubmit, + formState: { errors }, + control, + } = formMethods; + + const { mutateAsync } = api.form.submitForm.useMutation(); + + const [currentStep, setCurrentStep] = useState(0); + + const [isLoading, setIsLoading] = useState(false); + const [hasSubmitted, setHasSubmitted] = useState(); + const [error, setError] = useState< + { status?: string; message: string } | undefined + >(); + const router = useRouter(); + + const handleNextStep = () => { + if (currentStep === formFromProps.fields.length - 1) { + onSubmit(formMethods.getValues()); + } else { + setCurrentStep((prev) => prev + 1); + } + }; + + const handlePrevStep = () => { + setCurrentStep((prev) => prev - 1); + }; + + const onSubmit = useCallback( + (data: Data) => { + let loadingTimerID: ReturnType; + + const submitForm = async () => { + setError(undefined); + + const dataToSend = Object.entries(data).map(([name, value]) => ({ + field: name, + value, + })) as { field: string; value: string }[]; + + // delay loading indicator by 1s + loadingTimerID = setTimeout(() => { + setIsLoading(true); + }, 1000); + + try { + mutateAsync({ + formId: formID as unknown as number, + submissionData: dataToSend, + }); + + clearTimeout(loadingTimerID); + + setIsLoading(false); + setHasSubmitted(true); + + if (confirmationType === "redirect" && redirect) { + const { url } = redirect; + + const redirectUrl = url; + + if (redirectUrl) router.push(redirectUrl); + } + } catch (err) { + console.warn(err); + setIsLoading(false); + setError({ + message: "Something went wrong.", + }); + } + }; + + submitForm(); + }, + [router, formID, redirect, confirmationType] + ); + + return ( +
+ {/* {enableIntro && introContent && !hasSubmitted && ( +
{introContent}
+ )} */} + {!isLoading && hasSubmitted && confirmationType === "message" && ( +
{JSON.stringify(confirmationMessage, null, 2)}
+ )} + {isLoading && !hasSubmitted &&

Loading, please wait...

} + {error &&
{`${error.status || "500"}: ${error.message || ""}`}
} + {!hasSubmitted && ( + +
+ {formFromProps && formFromProps.fields && ( + + )} +
+ + {currentStep > 0 && ( + } + onClick={handlePrevStep} + px={6} + /> + )} + } + onClick={handleNextStep} + ml="auto" + px={6} + /> + +
+ )} +
+ ); +}; diff --git a/webapp/src/components/forms/payload/Ladder/index.tsx b/webapp/src/components/forms/payload/Ladder/index.tsx new file mode 100644 index 00000000..bdf4a4d8 --- /dev/null +++ b/webapp/src/components/forms/payload/Ladder/index.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { TextField } from "@payloadcms/plugin-form-builder/dist/types"; +import { + UseFormRegister, + FieldValues, + FieldErrorsImpl, + Controller, +} from "react-hook-form"; +import { + Flex, + FormControl, + FormLabel, + IconButton, + Text, +} from "@chakra-ui/react"; + +export const Ladder: React.FC<{ + register: UseFormRegister; + control: any; + errors: Partial< + FieldErrorsImpl<{ + [x: string]: any; + }> + >; + field: TextField & { + min: number; + max: number; + textLegend: { label: string }[]; + }; +}> = ({ field: currentField, register, control, errors }) => { + console.log("currentField", currentField); + + const ladderArr = Array.from( + { length: currentField.max - currentField.min + 1 }, + (_, index) => (index + currentField.min).toString() + ); + + return ( + + + {currentField.label} + + { + return ( + + + {ladderArr.map((item) => ( + {item}} + px={2.5} + borderRadius="base" + fontSize={16} + fontWeight={800} + minWidth="auto" + color={field.value === item ? "white" : "black"} + colorScheme={ + field.value === item ? "primaryShades" : "bgGrayShades" + } + onClick={() => field.onChange(item)} + /> + ))} + + + {currentField.textLegend.map((item) => ( + {item.label} + ))} + + + ); + }} + /> + + ); +}; diff --git a/webapp/src/components/forms/payload/Textarea/index.tsx b/webapp/src/components/forms/payload/Textarea/index.tsx new file mode 100644 index 00000000..4638dbfa --- /dev/null +++ b/webapp/src/components/forms/payload/Textarea/index.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { TextField } from "@payloadcms/plugin-form-builder/dist/types"; +import { + UseFormRegister, + FieldValues, + FieldErrorsImpl, + FieldError, +} from "react-hook-form"; +import FormInput from "../../FormInput"; + +export const Textarea: React.FC<{ + register: UseFormRegister; + errors: Partial< + FieldErrorsImpl<{ + [x: string]: any; + }> + >; + field: TextField; +}> = ({ register, field, errors }) => { + return ( + + ); +}; diff --git a/webapp/src/components/forms/payload/buildInitialFormState.tsx b/webapp/src/components/forms/payload/buildInitialFormState.tsx new file mode 100644 index 00000000..be27c426 --- /dev/null +++ b/webapp/src/components/forms/payload/buildInitialFormState.tsx @@ -0,0 +1,43 @@ +import { FormFieldBlock } from "@payloadcms/plugin-form-builder/dist/types"; + +export const buildInitialFormState = (fields: FormFieldBlock[]) => { + return fields.reduce((initialSchema, field) => { + if (field.blockType === "checkbox") { + return { + ...initialSchema, + [field.name]: false, + }; + } + if (field.blockType === "country") { + return { + ...initialSchema, + [field.name]: "", + }; + } + if (field.blockType === "email") { + return { + ...initialSchema, + [field.name]: "", + }; + } + if (field.blockType === "text") { + return { + ...initialSchema, + [field.name]: "", + }; + } + if (field.blockType === "select") { + return { + ...initialSchema, + [field.name]: "", + }; + } + if (field.blockType === "state") { + return { + ...initialSchema, + [field.name]: "", + }; + } + return initialSchema; + }, {}); +}; diff --git a/webapp/src/components/forms/payload/fields.tsx b/webapp/src/components/forms/payload/fields.tsx new file mode 100644 index 00000000..cb9db103 --- /dev/null +++ b/webapp/src/components/forms/payload/fields.tsx @@ -0,0 +1,7 @@ +import { Ladder } from "./Ladder"; +import { Textarea } from "./Textarea"; + +export const fields = { + number: Ladder, + textarea: Textarea, +}; diff --git a/webapp/src/pages/dashboard/test-form.tsx b/webapp/src/pages/dashboard/test-form.tsx new file mode 100644 index 00000000..76940c6e --- /dev/null +++ b/webapp/src/pages/dashboard/test-form.tsx @@ -0,0 +1,20 @@ +import { Button, Center, Text } from "@chakra-ui/react"; +import { FormBlock } from "~/components/forms/payload/Form"; + +import { api } from "~/utils/api"; + +export default function TextForm() { + const { data: resultForm } = api.form.getFormBySlug.useQuery({ + slug: "test-form", + }); + + const { data } = resultForm || {}; + + return ( +
+ This is a test form + {/*
{JSON.stringify(data, null, 2)}
*/} + {data && } +
+ ); +} diff --git a/webapp/src/payload/payload-form-builder.ts b/webapp/src/payload/payload-form-builder.ts new file mode 100644 index 00000000..7227473b --- /dev/null +++ b/webapp/src/payload/payload-form-builder.ts @@ -0,0 +1,40 @@ +import { PluginConfig } from "@payloadcms/plugin-form-builder/dist/types"; +import { fields } from "@payloadcms/plugin-form-builder"; + +export const FormBuilderConfig: PluginConfig = { + fields: { + textarea: true, + number: { + fields: [ + ...(fields.number as any).fields, + { + type: "row", + fields: [ + { type: "number", name: "min", label: "Minimum", min: 0 }, + { type: "number", name: "max", label: "Maximum", min: 1 }, + ], + }, + { + type: "array", + name: "textLegend", + label: "Text Legend", + fields: [{ type: "text", name: "label", label: "Label" }], + }, + ], + // fields: { + // // ...(fields.number.fields as any), + // // min: { + // // label: "Minimum", + // // type: "number", + // // }, + // }, + }, + checkbox: false, + country: false, + email: false, + message: false, + select: false, + state: false, + text: false, + }, +}; diff --git a/webapp/src/payload/payload-types.ts b/webapp/src/payload/payload-types.ts index f7c6f273..375c85e2 100644 --- a/webapp/src/payload/payload-types.ts +++ b/webapp/src/payload/payload-types.ts @@ -25,6 +25,8 @@ export interface Config { notifications: Notification; search_requests: SearchRequest; email_auth_tokens: EmailAuthToken; + forms: Form; + "form-submissions": FormSubmission; "payload-preferences": PayloadPreference; "payload-migrations": PayloadMigration; }; @@ -250,7 +252,7 @@ export interface Offer { | null; conditionBlocks?: | { - slug: string; + slug?: string | null; isCrossed?: boolean | null; id?: string | null; }[] @@ -399,6 +401,157 @@ export interface EmailAuthToken { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "forms". + */ +export interface Form { + id: number; + title: string; + fields?: + | ( + | { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + defaultValue?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: "checkbox"; + } + | { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: "country"; + } + | { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: "email"; + } + | { + message?: + | { + [k: string]: unknown; + }[] + | null; + id?: string | null; + blockName?: string | null; + blockType: "message"; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: "number"; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + options?: + | { + label: string; + value: string; + id?: string | null; + }[] + | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: "select"; + } + | { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: "state"; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: "text"; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: "textarea"; + } + )[] + | null; + submitButtonLabel?: string | null; + confirmationType?: ("message" | "redirect") | null; + confirmationMessage?: + | { + [k: string]: unknown; + }[] + | null; + redirect?: { + url: string; + }; + emails?: + | { + emailTo?: string | null; + cc?: string | null; + bcc?: string | null; + replyTo?: string | null; + emailFrom?: string | null; + subject: string; + message?: + | { + [k: string]: unknown; + }[] + | null; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "form-submissions". + */ +export interface FormSubmission { + id: number; + form: number | Form; + submissionData?: + | { + field: string; + value: string; + id?: string | null; + }[] + | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "payload-preferences". diff --git a/webapp/src/payload/payload.config.ts b/webapp/src/payload/payload.config.ts index 551fd17b..d4ee9534 100644 --- a/webapp/src/payload/payload.config.ts +++ b/webapp/src/payload/payload.config.ts @@ -5,6 +5,7 @@ import { slateEditor } from "@payloadcms/richtext-slate"; import { webpackBundler } from "@payloadcms/bundler-webpack"; import { cloudStorage } from "@payloadcms/plugin-cloud-storage"; import { s3Adapter } from "@payloadcms/plugin-cloud-storage/s3"; +import formBuilder from "@payloadcms/plugin-form-builder"; import { QuickAccess } from "./globals/QuickAccess"; import { LandingPartners } from "./globals/LandingPartners"; @@ -30,6 +31,7 @@ import { SearchRequests } from "./collections/SearchRequest"; import { EmailAuthTokens } from "./collections/EmailAuthToken"; import { Orders } from "./collections/Order"; import { OrderSignals } from "./collections/OrderSignal"; +import { FormBuilderConfig } from "./payload-form-builder"; const publicAdapter = s3Adapter({ config: { @@ -67,6 +69,7 @@ export default buildConfig({ }, }, }), + formBuilder(FormBuilderConfig), ], editor: slateEditor({}), admin: { diff --git a/webapp/src/server/api/root.ts b/webapp/src/server/api/root.ts index 85fb3b91..7453364b 100644 --- a/webapp/src/server/api/root.ts +++ b/webapp/src/server/api/root.ts @@ -12,6 +12,7 @@ import { tagRouter } from "./routers/tag"; import { searchRequestRouter } from "./routers/searchRequest"; import { widgetRouter } from "./routers/widget"; import { orderRouter } from "./routers/order"; +import { formRouter } from "./routers/form"; /** * This is the primary router for your server. @@ -31,6 +32,7 @@ export const appRouter = createTRPCRouter({ searchRequest: searchRequestRouter, widget: widgetRouter, order: orderRouter, + form: formRouter, }); // export type definition of API diff --git a/webapp/src/server/api/routers/form.ts b/webapp/src/server/api/routers/form.ts new file mode 100644 index 00000000..74e45a31 --- /dev/null +++ b/webapp/src/server/api/routers/form.ts @@ -0,0 +1,64 @@ +import { createTRPCRouter, userProtectedProcedure } from "~/server/api/trpc"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; + +export const formRouter = createTRPCRouter({ + getFormBySlug: userProtectedProcedure + .input(z.object({ slug: z.string() })) + .query(async ({ ctx, input }) => { + const { slug } = input; + + const forms = await ctx.payload.find({ + collection: "forms", + where: { + title: { + equals: slug, + }, + }, + depth: 3, + page: 1, + limit: 1, + }); + + if (!forms.docs.length) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Form not found", + }); + } + + const form = forms.docs[0]; + + return { + data: form, + }; + }), + + submitForm: userProtectedProcedure + .input( + z.object({ + formId: z.number(), + submissionData: z.array( + z.object({ + field: z.string(), + value: z.string(), + }) + ), + }) + ) + .mutation(async ({ ctx, input }) => { + const { formId, submissionData } = input; + + const submission = await ctx.payload.create({ + collection: "form-submissions", + data: { + form: formId, + submissionData: submissionData, + }, + }); + + return { + data: submission, + }; + }), +}); diff --git a/webapp/src/utils/chakra-theme.ts b/webapp/src/utils/chakra-theme.ts index 340c0120..cac553b2 100644 --- a/webapp/src/utils/chakra-theme.ts +++ b/webapp/src/utils/chakra-theme.ts @@ -221,6 +221,17 @@ export const theme = extendTheme({ "800": "#ffffff", "900": "#ffffff", }, + bgGrayShades: { + 100: "#FBFBFD", + 200: "#F8F8FB", + 300: "#F6F6FA", + 400: "#F4F4F9", + 500: "#F2F2F8", + 600: "#D9D9DF", + 700: "#C0C0C6", + 800: "#A7A7AD", + 900: "#8E8E94", + }, success: "#459F00", successLight: "#E9F6DF", error: "#F13C22", diff --git a/webapp/yarn.lock b/webapp/yarn.lock index cb822f05..49df6e0f 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -3927,6 +3927,19 @@ __metadata: languageName: node linkType: hard +"@payloadcms/plugin-form-builder@npm:1.2.1": + version: 1.2.1 + resolution: "@payloadcms/plugin-form-builder@npm:1.2.1" + dependencies: + deepmerge: "npm:^4.2.2" + escape-html: "npm:^1.0.3" + peerDependencies: + payload: ^0.18.5 || ^1.0.0 || ^2.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 10c0/b564b14d63c6462aa2b45e0cb7e4eed309c6efe566fc52ff8fd1323b2fcff8c6cce86ba8a2ff9c662568e95beb64092dfc526dbbdf46698d528c97fa74d8256e + languageName: node + linkType: hard + "@payloadcms/richtext-slate@npm:^1.3.1": version: 1.5.2 resolution: "@payloadcms/richtext-slate@npm:1.5.2" @@ -7309,7 +7322,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:4.3.1, deepmerge@npm:^4.0.0": +"deepmerge@npm:4.3.1, deepmerge@npm:^4.0.0, deepmerge@npm:^4.2.2": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" checksum: 10c0/e53481aaf1aa2c4082b5342be6b6d8ad9dfe387bc92ce197a66dea08bd4265904a087e75e464f14d1347cf2ac8afe1e4c16b266e0561cc5df29382d3c5f80044 @@ -8175,7 +8188,7 @@ __metadata: languageName: node linkType: hard -"escape-html@npm:~1.0.3": +"escape-html@npm:^1.0.3, escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" checksum: 10c0/524c739d776b36c3d29fa08a22e03e8824e3b2fd57500e5e44ecf3cc4707c34c60f9ca0781c0e33d191f2991161504c295e98f68c78fe7baa6e57081ec6ac0a3 @@ -15016,6 +15029,7 @@ __metadata: "@payloadcms/db-postgres": "npm:^0.7.0" "@payloadcms/next-payload": "npm:^0.1.11" "@payloadcms/plugin-cloud-storage": "npm:^1.1.1" + "@payloadcms/plugin-form-builder": "npm:1.2.1" "@payloadcms/richtext-slate": "npm:^1.3.1" "@sentry/nextjs": "npm:^7.100.1" "@serwist/next": "npm:^9.0.2" From 46b3d5fabb290add3711c2b484ecf4865d7a20f3 Mon Sep 17 00:00:00 2001 From: Antoine Lelong Date: Thu, 28 Nov 2024 15:25:19 +0100 Subject: [PATCH 2/5] fix: integrate form in coupon used feedback modal and add missing fields in textarea and number fields --- webapp/src/components/forms/FormInput.tsx | 8 +- webapp/src/components/forms/payload/Form.tsx | 60 +++++--- .../components/forms/payload/Ladder/index.tsx | 10 +- webapp/src/components/modals/ConfirmModal.tsx | 2 +- .../modals/CouponUsedFeedbackModal.tsx | 136 +++++++++++++----- .../components/offer/page/CouponUsedBox.tsx | 38 +---- webapp/src/pages/dashboard/test-form.tsx | 20 --- webapp/src/payload/payload-form-builder.ts | 18 +-- 8 files changed, 172 insertions(+), 120 deletions(-) delete mode 100644 webapp/src/pages/dashboard/test-form.tsx diff --git a/webapp/src/components/forms/FormInput.tsx b/webapp/src/components/forms/FormInput.tsx index d4790c95..a0c84a52 100644 --- a/webapp/src/components/forms/FormInput.tsx +++ b/webapp/src/components/forms/FormInput.tsx @@ -80,9 +80,11 @@ const FormInput = ({ : {} } > - - {label} - + {label && ( + + {label} + + )} { +}: { + formFromProps: FormType; + register: any; + errors: any; + control: any; + formMethods: any; + field: any; +}) => { const Field: React.FC = fields?.[field.blockType as keyof typeof fields]; if (Field) { return ( @@ -54,6 +68,7 @@ const FormField = ({ export const FormBlock: React.FC< FormBlockType & { id?: string; + afterOnSubmit: () => void; } > = (props) => { const { @@ -66,6 +81,7 @@ export const FormBlock: React.FC< redirect, confirmationMessage, } = {}, + afterOnSubmit, } = props; const formMethods = useForm({ @@ -119,7 +135,7 @@ export const FormBlock: React.FC< }, 1000); try { - mutateAsync({ + await mutateAsync({ formId: formID as unknown as number, submissionData: dataToSend, }); @@ -129,13 +145,14 @@ export const FormBlock: React.FC< setIsLoading(false); setHasSubmitted(true); - if (confirmationType === "redirect" && redirect) { - const { url } = redirect; + afterOnSubmit(); + // if (confirmationType === "redirect" && redirect) { + // const { url } = redirect; - const redirectUrl = url; + // const redirectUrl = url; - if (redirectUrl) router.push(redirectUrl); - } + // if (redirectUrl) router.push(redirectUrl); + // } } catch (err) { console.warn(err); setIsLoading(false); @@ -164,18 +181,25 @@ export const FormBlock: React.FC<
{formFromProps && formFromProps.fields && ( - + + {"label" in formFromProps.fields[currentStep] && ( + + {formFromProps.fields[currentStep].label} + + )} + + )}
- {currentStep > 0 && ( + {/* {currentStep > 0 && ( - )} + )} */} = ({ field: currentField, register, control, errors }) => { +}> = ({ field: currentField, control }) => { console.log("currentField", currentField); const ladderArr = Array.from( @@ -51,14 +51,14 @@ export const Ladder: React.FC<{ render={({ field }) => { return ( - + {ladderArr.map((item) => ( {item}} - px={2.5} + h={10} borderRadius="base" fontSize={16} fontWeight={800} @@ -73,7 +73,9 @@ export const Ladder: React.FC<{ {currentField.textLegend.map((item) => ( - {item.label} + + {item.label} + ))} diff --git a/webapp/src/components/modals/ConfirmModal.tsx b/webapp/src/components/modals/ConfirmModal.tsx index 5d8fdca0..04997996 100644 --- a/webapp/src/components/modals/ConfirmModal.tsx +++ b/webapp/src/components/modals/ConfirmModal.tsx @@ -10,7 +10,7 @@ import { type ConfirmModalProps = { title: string; - description?: string; + description?: string | React.ReactNode; labels?: { primary?: string; secondary?: string; diff --git a/webapp/src/components/modals/CouponUsedFeedbackModal.tsx b/webapp/src/components/modals/CouponUsedFeedbackModal.tsx index 5295ee25..e250ead6 100644 --- a/webapp/src/components/modals/CouponUsedFeedbackModal.tsx +++ b/webapp/src/components/modals/CouponUsedFeedbackModal.tsx @@ -6,7 +6,14 @@ import { ModalContent, ModalOverlay, Button, + ButtonGroup, + ModalCloseButton, + Box, } from "@chakra-ui/react"; +import { useState } from "react"; +import { api } from "~/utils/api"; +import { FormBlock } from "../forms/payload/Form"; +import { Form } from "~/payload/payload-types"; type CouponUsedFeedbackModalProps = { isOpen: boolean; @@ -14,50 +21,115 @@ type CouponUsedFeedbackModalProps = { onConfirm: () => void; }; -const CouponUsedFeedbackModal = (props: CouponUsedFeedbackModalProps) => { - const { isOpen, onClose, onConfirm } = props; +const CouponUsedFeedbackModalContent = ({ + couponUsedFeedbackForm, + onConfirm, + onClose, +}: { + couponUsedFeedbackForm: Form | undefined; + onConfirm: () => void; + onClose: () => void; +}) => { + const [currentStep, setCurrentStep] = useState<"form" | "finish" | undefined>( + undefined + ); - return ( - - - - - - - Tout s'est bien passé quand vous avez utilisé le code ? - + switch (currentStep) { + case "form": + return ( + + {couponUsedFeedbackForm && ( + setCurrentStep("finish")} + enableIntro={true} + /> + )} + + ); + case "finish": + return ( + + + Merci pour votre avis ! + + + + ); + default: + return ( + + + Vous avez utilisé ce code ? + + + Ce code là n’est valable qu’1 fois +
+
+ Vous ne pourrez plus le réutiliser ensuite +
+
+ Vous pouvez obtenir un nouveau code dans 24h +
+
+ Confirmer que vous avez utilisé ce code ? +
+ -
+
+ + ); + } +}; + +const CouponUsedFeedbackModal = (props: CouponUsedFeedbackModalProps) => { + const { isOpen, onClose, onConfirm } = props; + + const { data: resultForm } = api.form.getFormBySlug.useQuery({ + slug: "coupon-used-feedback-form", + }); + + const { data: form } = resultForm || {}; + + return ( + + + + + + diff --git a/webapp/src/components/offer/page/CouponUsedBox.tsx b/webapp/src/components/offer/page/CouponUsedBox.tsx index 86151fbe..60cd7956 100644 --- a/webapp/src/components/offer/page/CouponUsedBox.tsx +++ b/webapp/src/components/offer/page/CouponUsedBox.tsx @@ -1,6 +1,5 @@ import { Flex, Text, Button, useDisclosure } from "@chakra-ui/react"; import { useState } from "react"; -import ConfirmModal from "~/components/modals/ConfirmModal"; import CouponUsedFeedbackModal from "~/components/modals/CouponUsedFeedbackModal"; import { CouponIncluded } from "~/server/api/routers/coupon"; import { api } from "~/utils/api"; @@ -15,40 +14,25 @@ const CouponUsedBox = (props: CouponUsedBoxProps) => { const [showUsedBox, setShowUsedBox] = useState(true); - const { - isOpen: isOpenCouponUsedModal, - onOpen: onOpenCouponUsedModal, - onClose: onCloseCouponUsedModal, - } = useDisclosure(); - const { isOpen: isOpenCouponUsedFeedbackModal, onOpen: onOpenCouponUsedFeedbackModal, onClose: onCloseCouponUsedFeedbackModal, } = useDisclosure(); - const { mutateAsync: mutateCouponUsed } = api.coupon.usedFromUser.useMutation( - { - onSuccess: () => { - onOpenCouponUsedFeedbackModal(); - }, - } - ); + const { mutateAsync: mutateCouponUsed } = + api.coupon.usedFromUser.useMutation(); const handleCouponUsed = (used: boolean) => { if (!used) { setShowUsedBox(false); } else { - onOpenCouponUsedModal(); + onOpenCouponUsedFeedbackModal(); } }; const closeFeedbackModal = () => { confirmCouponUsed(); - window.open( - "https://surveys.hotjar.com/8d25a606-6e24-4437-97be-75fcdb4c3e35", - "_blank" - ); onCloseCouponUsedFeedbackModal(); }; @@ -90,24 +74,10 @@ const CouponUsedBox = (props: CouponUsedBoxProps) => { Oui - { - mutateCouponUsed({ coupon_id: coupon.id }); - }} - placement="center" - /> mutateCouponUsed({ coupon_id: coupon.id })} /> ); diff --git a/webapp/src/pages/dashboard/test-form.tsx b/webapp/src/pages/dashboard/test-form.tsx deleted file mode 100644 index 76940c6e..00000000 --- a/webapp/src/pages/dashboard/test-form.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Button, Center, Text } from "@chakra-ui/react"; -import { FormBlock } from "~/components/forms/payload/Form"; - -import { api } from "~/utils/api"; - -export default function TextForm() { - const { data: resultForm } = api.form.getFormBySlug.useQuery({ - slug: "test-form", - }); - - const { data } = resultForm || {}; - - return ( -
- This is a test form - {/*
{JSON.stringify(data, null, 2)}
*/} - {data && } -
- ); -} diff --git a/webapp/src/payload/payload-form-builder.ts b/webapp/src/payload/payload-form-builder.ts index 7227473b..0bfa4ea0 100644 --- a/webapp/src/payload/payload-form-builder.ts +++ b/webapp/src/payload/payload-form-builder.ts @@ -3,7 +3,16 @@ import { fields } from "@payloadcms/plugin-form-builder"; export const FormBuilderConfig: PluginConfig = { fields: { - textarea: true, + textarea: { + fields: [ + ...(fields.textarea as any).fields, + { + type: "text", + name: "placeholder", + label: "Placeholder", + }, + ], + }, number: { fields: [ ...(fields.number as any).fields, @@ -21,13 +30,6 @@ export const FormBuilderConfig: PluginConfig = { fields: [{ type: "text", name: "label", label: "Label" }], }, ], - // fields: { - // // ...(fields.number.fields as any), - // // min: { - // // label: "Minimum", - // // type: "number", - // // }, - // }, }, checkbox: false, country: false, From 19276bd766ca0cbcda07f35cc86ceba4962b93d0 Mon Sep 17 00:00:00 2001 From: Antoine Lelong Date: Thu, 28 Nov 2024 17:33:25 +0100 Subject: [PATCH 3/5] fix: change number input to country to prevent name collision, add base fields for textarea and ladder --- webapp/src/components/forms/payload/Form.tsx | 107 +++++------------- .../components/forms/payload/Ladder/index.tsx | 18 +-- .../src/components/forms/payload/fields.tsx | 2 +- .../modals/CouponUsedFeedbackModal.tsx | 1 - webapp/src/payload/payload-form-builder.ts | 59 +++++++--- webapp/src/payload/payload.config.ts | 4 +- 6 files changed, 82 insertions(+), 109 deletions(-) diff --git a/webapp/src/components/forms/payload/Form.tsx b/webapp/src/components/forms/payload/Form.tsx index d1bdd58f..08167105 100644 --- a/webapp/src/components/forms/payload/Form.tsx +++ b/webapp/src/components/forms/payload/Form.tsx @@ -32,39 +32,6 @@ export type FormBlockType = { form: FormType; }; -const FormField = ({ - formFromProps, - register, - errors, - control, - formMethods, - field, -}: { - formFromProps: FormType; - register: any; - errors: any; - control: any; - formMethods: any; - field: any; -}) => { - const Field: React.FC = fields?.[field.blockType as keyof typeof fields]; - if (Field) { - return ( - - - - ); - } - return null; -}; - export const FormBlock: React.FC< FormBlockType & { id?: string; @@ -72,15 +39,8 @@ export const FormBlock: React.FC< } > = (props) => { const { - enableIntro, form: formFromProps, - form: { - id: formID, - submitButtonLabel, - confirmationType, - redirect, - confirmationMessage, - } = {}, + form: { id: formID, confirmationType, redirect } = {}, afterOnSubmit, } = props; @@ -113,9 +73,9 @@ export const FormBlock: React.FC< } }; - const handlePrevStep = () => { - setCurrentStep((prev) => prev - 1); - }; + // const handlePrevStep = () => { + // setCurrentStep((prev) => prev - 1); + // }; const onSubmit = useCallback( (data: Data) => { @@ -129,7 +89,6 @@ export const FormBlock: React.FC< value, })) as { field: string; value: string }[]; - // delay loading indicator by 1s loadingTimerID = setTimeout(() => { setIsLoading(true); }, 1000); @@ -146,13 +105,6 @@ export const FormBlock: React.FC< setHasSubmitted(true); afterOnSubmit(); - // if (confirmationType === "redirect" && redirect) { - // const { url } = redirect; - - // const redirectUrl = url; - - // if (redirectUrl) router.push(redirectUrl); - // } } catch (err) { console.warn(err); setIsLoading(false); @@ -169,34 +121,36 @@ export const FormBlock: React.FC< return (
- {/* {enableIntro && introContent && !hasSubmitted && ( -
{introContent}
- )} */} - {!isLoading && hasSubmitted && confirmationType === "message" && ( -
{JSON.stringify(confirmationMessage, null, 2)}
- )} - {isLoading && !hasSubmitted &&

Loading, please wait...

} {error &&
{`${error.status || "500"}: ${error.message || ""}`}
} {!hasSubmitted && (
- {formFromProps && formFromProps.fields && ( - - {"label" in formFromProps.fields[currentStep] && ( - - {formFromProps.fields[currentStep].label} - - )} - - - )} + {formFromProps && + formFromProps.fields && + (() => { + const field = formFromProps.fields[currentStep]; + const Field: React.FC = + fields?.[field.blockType as keyof typeof fields]; + return ( + + {"label" in field && ( + + {field.label} + + )} + + + + + ); + })()}
{/* {currentStep > 0 && ( @@ -211,6 +165,7 @@ export const FormBlock: React.FC< } onClick={handleNextStep} ml="auto" diff --git a/webapp/src/components/forms/payload/Ladder/index.tsx b/webapp/src/components/forms/payload/Ladder/index.tsx index 53c62906..f4b1d1d7 100644 --- a/webapp/src/components/forms/payload/Ladder/index.tsx +++ b/webapp/src/components/forms/payload/Ladder/index.tsx @@ -28,8 +28,6 @@ export const Ladder: React.FC<{ textLegend: { label: string }[]; }; }> = ({ field: currentField, control }) => { - console.log("currentField", currentField); - const ladderArr = Array.from( { length: currentField.max - currentField.min + 1 }, (_, index) => (index + currentField.min).toString() @@ -37,18 +35,10 @@ export const Ladder: React.FC<{ return ( - - {currentField.label} - { + render={({ field: { onChange, value } }) => { return ( @@ -63,11 +53,11 @@ export const Ladder: React.FC<{ fontSize={16} fontWeight={800} minWidth="auto" - color={field.value === item ? "white" : "black"} + color={value === item ? "white" : "black"} colorScheme={ - field.value === item ? "primaryShades" : "bgGrayShades" + value === item ? "primaryShades" : "bgGrayShades" } - onClick={() => field.onChange(item)} + onClick={() => onChange(item)} /> ))} diff --git a/webapp/src/components/forms/payload/fields.tsx b/webapp/src/components/forms/payload/fields.tsx index cb9db103..eef9c00f 100644 --- a/webapp/src/components/forms/payload/fields.tsx +++ b/webapp/src/components/forms/payload/fields.tsx @@ -2,6 +2,6 @@ import { Ladder } from "./Ladder"; import { Textarea } from "./Textarea"; export const fields = { - number: Ladder, + country: Ladder, textarea: Textarea, }; diff --git a/webapp/src/components/modals/CouponUsedFeedbackModal.tsx b/webapp/src/components/modals/CouponUsedFeedbackModal.tsx index e250ead6..57394fdb 100644 --- a/webapp/src/components/modals/CouponUsedFeedbackModal.tsx +++ b/webapp/src/components/modals/CouponUsedFeedbackModal.tsx @@ -98,7 +98,6 @@ const CouponUsedFeedbackModalContent = ({ p={7} colorScheme="blackBtn" onClick={() => { - onConfirm(); setCurrentStep(couponUsedFeedbackForm ? "form" : "finish"); }} > diff --git a/webapp/src/payload/payload-form-builder.ts b/webapp/src/payload/payload-form-builder.ts index 0bfa4ea0..5e9f3e7a 100644 --- a/webapp/src/payload/payload-form-builder.ts +++ b/webapp/src/payload/payload-form-builder.ts @@ -1,21 +1,36 @@ -import { PluginConfig } from "@payloadcms/plugin-form-builder/dist/types"; import { fields } from "@payloadcms/plugin-form-builder"; +import { PluginConfig } from "@payloadcms/plugin-form-builder/dist/types"; +import { Field } from "payload/types"; + +const baseFields: Field[] = [ + { + type: "row", + fields: [ + { + type: "text", + name: "name", + required: true, + label: "Nom (miniscule, pas de caractères spéciaux)", + }, + { type: "text", name: "label", label: "Libellé" }, + ], + }, + { + type: "checkbox", + name: "required", + label: "Requis", + }, +]; -export const FormBuilderConfig: PluginConfig = { +export const formBuilderConfig: PluginConfig = { fields: { - textarea: { + country: { + labels: { + singular: "Échelle de valeurs", + plural: "Échelles de valeurs", + }, fields: [ - ...(fields.textarea as any).fields, - { - type: "text", - name: "placeholder", - label: "Placeholder", - }, - ], - }, - number: { - fields: [ - ...(fields.number as any).fields, + ...baseFields, { type: "row", fields: [ @@ -31,8 +46,22 @@ export const FormBuilderConfig: PluginConfig = { }, ], }, + textarea: { + labels: { + singular: "Zone de texte", + plural: "Zones de texte", + }, + fields: [ + ...baseFields, + { + type: "text", + name: "placeholder", + label: "Placeholder", + }, + ], + }, + number: false, checkbox: false, - country: false, email: false, message: false, select: false, diff --git a/webapp/src/payload/payload.config.ts b/webapp/src/payload/payload.config.ts index d4ee9534..89637910 100644 --- a/webapp/src/payload/payload.config.ts +++ b/webapp/src/payload/payload.config.ts @@ -31,7 +31,7 @@ import { SearchRequests } from "./collections/SearchRequest"; import { EmailAuthTokens } from "./collections/EmailAuthToken"; import { Orders } from "./collections/Order"; import { OrderSignals } from "./collections/OrderSignal"; -import { FormBuilderConfig } from "./payload-form-builder"; +import { formBuilderConfig } from "./payload-form-builder"; const publicAdapter = s3Adapter({ config: { @@ -69,7 +69,7 @@ export default buildConfig({ }, }, }), - formBuilder(FormBuilderConfig), + formBuilder(formBuilderConfig), ], editor: slateEditor({}), admin: { From ace04e93439948053a9a56da308b26347a115e15 Mon Sep 17 00:00:00 2001 From: Antoine Lelong Date: Thu, 28 Nov 2024 17:58:00 +0100 Subject: [PATCH 4/5] fix: add script to seed form coupon used feedback --- webapp/package.json | 1 + webapp/src/payload/seed/forms/index.ts | 65 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 webapp/src/payload/seed/forms/index.ts diff --git a/webapp/package.json b/webapp/package.json index 6acaf0d1..72cf8846 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -8,6 +8,7 @@ "start": "next start", "seed:dev": "PAYLOAD_DROP_DATABASE=true tsx ./src/payload/seed/index.ts", "seed:prod": "tsx ./src/payload/seed/index.ts", + "seed:forms": "tsx ./src/payload/seed/forms/index.ts", "cron-job": "tsx ./src/notifications/index.ts", "payload": "PAYLOAD_CONFIG_PATH=./src/payload/payload.config.ts payload", "format": "prettier --write .", diff --git a/webapp/src/payload/seed/forms/index.ts b/webapp/src/payload/seed/forms/index.ts new file mode 100644 index 00000000..f10a106d --- /dev/null +++ b/webapp/src/payload/seed/forms/index.ts @@ -0,0 +1,65 @@ +import "ignore-styles"; +import "dotenv/config"; +import { getPayloadClient } from "../../payloadClient"; + +export const seedData = async () => { + try { + const payload = await getPayloadClient({ + seed: true, + }); + + const forms = await payload.find({ + collection: "forms", + where: { + title: { + equals: "coupon-used-feedback-form", + }, + }, + }); + + if (forms.docs.length === 0) { + await payload.create({ + collection: "forms", + data: { + title: "coupon-used-feedback-form", + fields: [ + { + blockType: "country", + name: "coupon-satisfaction", + label: "Comment ça s’est passé avec la réduction ?", + required: true, + min: 0, + max: 10, + textLegend: [{ label: "Catasrophique" }, { label: "Génial" }], + }, + { + blockType: "country", + name: "coupon-complexity", + label: "Utiliser la réduction vous avez trouvé ça...", + required: true, + min: 0, + max: 5, + textLegend: [ + { label: "Super complexe" }, + { label: "Très simple" }, + ], + }, + { + blockType: "textarea", + name: "coupon-text-feedback", + label: "Que faut-il améliorer selon vous ?", + placeholder: "Décrivez ce qu’on peut améliorer ici...", + }, + ] as any, + confirmationMessage: [{ children: [{ text: "." }] }], + }, + }); + } + } catch (e) { + console.error(e); + } finally { + process.exit(); + } +}; + +seedData(); From 0d6bd20aa89af5e9b87c21e4f9a467dddd0235fc Mon Sep 17 00:00:00 2001 From: Antoine Lelong Date: Thu, 28 Nov 2024 18:13:50 +0100 Subject: [PATCH 5/5] fix: confirm coupon after close modal --- .../modals/CouponUsedFeedbackModal.tsx | 26 ++++++++++++++----- .../components/offer/page/CouponUsedBox.tsx | 9 +++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/webapp/src/components/modals/CouponUsedFeedbackModal.tsx b/webapp/src/components/modals/CouponUsedFeedbackModal.tsx index 57394fdb..88c00018 100644 --- a/webapp/src/components/modals/CouponUsedFeedbackModal.tsx +++ b/webapp/src/components/modals/CouponUsedFeedbackModal.tsx @@ -23,17 +23,17 @@ type CouponUsedFeedbackModalProps = { const CouponUsedFeedbackModalContent = ({ couponUsedFeedbackForm, + currentStep, + setCurrentStep, onConfirm, onClose, }: { couponUsedFeedbackForm: Form | undefined; + currentStep: "form" | "finish" | undefined; + setCurrentStep: (step: "form" | "finish" | undefined) => void; onConfirm: () => void; onClose: () => void; }) => { - const [currentStep, setCurrentStep] = useState<"form" | "finish" | undefined>( - undefined - ); - switch (currentStep) { case "form": return ( @@ -88,7 +88,7 @@ const CouponUsedFeedbackModalContent = ({ fontSize="sm" p={7} fontWeight={800} - onClick={() => onClose()} + onClick={onClose} > Non @@ -112,22 +112,34 @@ const CouponUsedFeedbackModalContent = ({ const CouponUsedFeedbackModal = (props: CouponUsedFeedbackModalProps) => { const { isOpen, onClose, onConfirm } = props; + const [currentStep, setCurrentStep] = useState<"form" | "finish" | undefined>( + undefined + ); + const { data: resultForm } = api.form.getFormBySlug.useQuery({ slug: "coupon-used-feedback-form", }); const { data: form } = resultForm || {}; + const closeModal = () => { + setCurrentStep(undefined); + if (currentStep) onConfirm(); + onClose(); + }; + return ( - + diff --git a/webapp/src/components/offer/page/CouponUsedBox.tsx b/webapp/src/components/offer/page/CouponUsedBox.tsx index 60cd7956..7fc400e7 100644 --- a/webapp/src/components/offer/page/CouponUsedBox.tsx +++ b/webapp/src/components/offer/page/CouponUsedBox.tsx @@ -11,6 +11,7 @@ type CouponUsedBoxProps = { const CouponUsedBox = (props: CouponUsedBoxProps) => { const { coupon, confirmCouponUsed } = props; + const utils = api.useUtils(); const [showUsedBox, setShowUsedBox] = useState(true); @@ -20,8 +21,12 @@ const CouponUsedBox = (props: CouponUsedBoxProps) => { onClose: onCloseCouponUsedFeedbackModal, } = useDisclosure(); - const { mutateAsync: mutateCouponUsed } = - api.coupon.usedFromUser.useMutation(); + const { mutateAsync: mutateCouponUsed } = api.coupon.usedFromUser.useMutation( + { + onSuccess: () => + utils.coupon.getOne.invalidate({ offer_id: coupon.offer.id }), + } + ); const handleCouponUsed = (used: boolean) => { if (!used) {