diff --git a/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss b/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss index 69a57631a59..5b135936b4e 100644 --- a/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss +++ b/documentation/ag-grid-docs/src/components/docs/components/Header.module.scss @@ -7,10 +7,6 @@ .docsPageTitle { margin-bottom: $spacing-size-1; - span:nth-of-type(2) { - letter-spacing: -1.2px; - } - @media screen and (min-width: $breakpoint-docs-nav-medium) { line-height: var(--line-height-ultra-tight); } @@ -60,8 +56,6 @@ .version { font-size: var(--text-fs-sm); font-weight: var(--text-regular); - // The header's nth-of-type span rule would otherwise tighten this. - letter-spacing: normal; color: var(--color-text-tertiary); } diff --git a/documentation/ag-grid-docs/src/constants.ts b/documentation/ag-grid-docs/src/constants.ts index 1cdbc2f4314..e8ebe511fd0 100644 --- a/documentation/ag-grid-docs/src/constants.ts +++ b/documentation/ag-grid-docs/src/constants.ts @@ -196,8 +196,6 @@ export const DEBUG_SCRIPT_FILE_NAME = 'ag-grid-debug.js'; export const PRODUCTION_CHANGELOG_JSON_URL = 'https://www.ag-grid.com/changelog/changelog.json'; export const ZI_FORM_ID = 'aad0527d-5af6-4263-8dcd-60f3ac998d5d'; -export type TrialLicenceFormType = 'emailOnly' | 'allFields' | 'original'; -export const DEFAULT_TRIAL_LICENSE_FORM: TrialLicenceFormType = 'allFields'; // Google Tag Manager export const PUBLIC_GTM_ID = import.meta.env?.PUBLIC_GTM_ID; diff --git a/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json b/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json index df65eb82912..8ad0d919f9e 100644 --- a/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json +++ b/documentation/ag-grid-docs/src/content/api-documentation/grid-api/api.json @@ -568,6 +568,12 @@ "name": "Edit Validation", "url": "./cell-editing-validation" } + }, + "getEditValidationErrors": { + "more": { + "name": "Edit Validation", + "url": "./cell-editing-validation" + } } }, "export": { diff --git a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/scheduler.json b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/scheduler.json index 872c13ef97e..20561212787 100644 --- a/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/scheduler.json +++ b/documentation/ag-grid-docs/src/content/campaigns/bryntum-products/scheduler.json @@ -113,6 +113,13 @@ "eyebrow": "Pro Edition", "heading": "Want Scheduling Logic Built In?", "body_text": "Want Scheduling Logic Built In? Try Scheduler Pro Compare Features", + "ctas": [ + { + "text": "See Scheduler Pro", + "href": "/products/scheduler-pro/", + "title": "See Scheduler Pro" + } + ], "media": [] }, { diff --git a/documentation/ag-grid-docs/src/content/docs/skills/index.mdoc b/documentation/ag-grid-docs/src/content/docs/skills/index.mdoc index f3320753b7e..a61d4a30c6a 100644 --- a/documentation/ag-grid-docs/src/content/docs/skills/index.mdoc +++ b/documentation/ag-grid-docs/src/content/docs/skills/index.mdoc @@ -11,6 +11,8 @@ We are actively developing and releasing new versions of our skills. Feedback is ## Setup +One skills package covers all of our products: [AG Grid](https://www.ag-grid.com/), [AG Charts](https://www.ag-grid.com/charts/) and [AG Studio](https://www.ag-grid.com/studio/). + ### Installation A skill is a folder of files that you install in your project repository or your home directory. Use the skills CLI to copy the files from the [ag-grid/skills](https://github.com/ag-grid/skills) repository to your machine: @@ -19,13 +21,9 @@ A skill is a folder of files that you install in your project repository or your npx skills add ag-grid/skills ``` -{% note %} -In order for `npx skills add` to install skills into your repo, the config folder for your agent must already exist, e.g. `.claude/` or `.codex/` in your repo root. This is normally created the first time you run the agent in the repo. -{% /note %} - ### Updating -We refine the skills and extend them to cover new AG Grid and AG Charts releases, so update at any time to pick up the latest guidance: +We refine the skills and extend them to cover new Grid, Charts and Studio releases, so update at any time to pick up the latest guidance: ```bash npx skills update ag-grid/skills @@ -33,25 +31,39 @@ npx skills update ag-grid/skills ## Skills -Each skill teaches your coding agent how to carry out a specific task with AG Grid and AG Charts, following our official guidance. Once installed, invoke a skill by name and the agent works through it for you. +We currently provide two skills. If there's something else you'd like to see, [open an issue](https://github.com/ag-grid/skills/issues) to suggest a skill. + +### ag-dev + +`ag-dev` is a general development assistant that helps agents write correct code on the first attempt. + +Coding harnesses like Claude Code and Codex will often automatically use it when planning or writing code that interacts with our libraries - simply installing the skill will improve the results you get during development. However, because automatic triggering is best-effort, for the most consistent results we recommend explicitly telling your agent to use the ag-dev skill, especially when working on complicated tasks: + +```bash +Add a custom context-menu item that copies the names of selected data +columns (use the ag-dev skill) +``` + +It includes a list of rules for the agent to follow to avoid common LLM pitfalls, debugging guidance, and instructions for accessing version-matched documentation to ensure that your agent uses the APIs for the installed version of the library. -## ag-update +### ag-update Upgrading across major versions normally means reading every release's breaking changes and working out which ones touch your code. The `ag-update` skill hands that work to your coding agent: it reads the AG Grid migration documentation, checks it against your repository, and produces a Markdown file listing the changes needed to move to a new version. To let the skill detect everything for you, invoke it from your coding agent: -``` +```bash Use the ag-update skill to upgrade my-app ``` You can also tell it exactly what to update, down to individual projects and target versions. -``` -Use the ag-update skill to update all projects in this monorepo except apps/demo-app to AG Grid v36 +```bash +Use the ag-update skill to update all projects in this monorepo +except apps/demo-app to AG Grid v36 ``` -### Example run +#### Example run A typical run, updating a single project to AG Grid v36, looks like this: @@ -77,18 +89,18 @@ Done — performance/typescript is on AG Grid 36.0.0 and AG Charts 14.0.0. `AG_UPDATE_CHANGES.md` contains an entry for each change, grouped by product and version transition, labelled `BREAKING` or `BEHAVIOUR`, and paired with how to mitigate it: -```md +```bash ## Grid v35.x -> v36.x ### BEHAVIOUR: ValidationModule no longer bundled by default -Change: The `ValidationModule` is no longer included in the `AllCommunityModule` and `AllEnterpriseModule` bundles, keeping production bundles smaller by default. Without it, console messages are reduced to an error code and a documentation link. +Change: The `ValidationModule` is no longer included in the +`AllCommunityModule` and `AllEnterpriseModule` bundles, keeping +production bundles smaller by default. Without it, console messages +are reduced to an error code and a documentation link. -Mitigation: To restore the full development-time diagnostics, call `enableDevValidations()` before any grid is created. +Mitigation: To restore the full development-time diagnostics, call +`enableDevValidations()` before any grid is created. ``` Review the generated files, then ask the agent to apply the plan. It bumps the versions in `package.json`, reinstalls dependencies, and validates the result against your build, typecheck, and dev server. - -## Upcoming skills - -We are expanding the set of skills to cover more of the AG Grid and AG Charts development workflow. Watch the [ag-grid/skills](https://github.com/ag-grid/skills) repository, or [open an issue](https://github.com/ag-grid/skills/issues) to suggest a skill. diff --git a/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.module.scss b/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.module.scss new file mode 100644 index 00000000000..e351c55edcc --- /dev/null +++ b/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.module.scss @@ -0,0 +1,25 @@ +@use 'design-system' as *; + +.consentLabel { + display: flex; + align-items: flex-start; + gap: $spacing-size-2; + cursor: pointer; + + input[type='checkbox'] { + // Keep the box aligned with the first line of the label text + flex-shrink: 0; + margin-top: 1px; + } +} + +.errorContainer { + display: flex; + align-items: flex-start; + margin-left: 1.75rem; +} + +// Indents a checkbox that is only revealed by the one above it +.nested { + margin-left: 1.75rem; +} diff --git a/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.tsx b/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.tsx new file mode 100644 index 00000000000..d37868a1144 --- /dev/null +++ b/external/ag-website-shared/src/components/consent-fields/ConsentCheckbox.tsx @@ -0,0 +1,26 @@ +import classnames from 'classnames'; +import type { ComponentPropsWithRef, FunctionComponent, ReactNode } from 'react'; + +import styles from './ConsentCheckbox.module.scss'; + +interface Props { + id: string; + label: ReactNode; + /** Validation message; presence of a message puts the field into its error state */ + error?: string; + /** Indents the field, for a checkbox only revealed by the one above it */ + nested?: boolean; + inputProps: ComponentPropsWithRef<'input'>; +} + +export const ConsentCheckbox: FunctionComponent = ({ id, label, error, nested, inputProps }: Props) => { + return ( +
+ +
{error &&

{error}

}
+
+ ); +}; diff --git a/external/ag-website-shared/src/components/consent-fields/consentMessages.tsx b/external/ag-website-shared/src/components/consent-fields/consentMessages.tsx new file mode 100644 index 00000000000..2058913dc69 --- /dev/null +++ b/external/ag-website-shared/src/components/consent-fields/consentMessages.tsx @@ -0,0 +1,26 @@ +import { PRIVACY_POLICY_URL } from '@ag-website-shared/constants'; +import type { ReactElement } from 'react'; + +/** + * Consent copy shared by every web-to-lead form, so the wording stays identical + * across the site. See AG-17996. + */ +export const CONSENT_LABELS: Record = { + dataProcessing: ( + <> + I agree to my data being processed in accordance with the{' '} + + Privacy Policy + + + ), + marketingEmail: "I'd like to receive product updates and marketing emails", + emailTracking: 'I consent to tracking of email opens and clicks', + /** + * Temporary stand-in for IP geolocation: email tracking consent only applies to + * France and Italy, so visitors declare it themselves to reveal that checkbox. + */ + franceOrItaly: 'I live in France or Italy', +}; + +export const DATA_PROCESSING_CONSENT_REQUIRED = 'You must agree to the Privacy Policy to continue'; diff --git a/external/ag-website-shared/src/components/contact-form/ContactForm.module.scss b/external/ag-website-shared/src/components/contact-form/ContactForm.module.scss index cb008677519..f105710248b 100644 --- a/external/ag-website-shared/src/components/contact-form/ContactForm.module.scss +++ b/external/ag-website-shared/src/components/contact-form/ContactForm.module.scss @@ -53,15 +53,10 @@ font-family: inherit; } -.privacyMessage { - font-size: var(--text-fs-xs); - line-height: var(--text-lh-xs); - color: color-mix(in srgb, var(--color-text-secondary), var(--color-bg-primary) 30%); - margin: 0; - - a { - font-weight: var(--text-regular); - } +.consents { + display: flex; + flex-direction: column; + gap: $spacing-size-2; } .tertiaryButton { @@ -73,6 +68,8 @@ } .errorContainer { + display: flex; + align-items: flex-start; margin-top: 0.25rem; &:empty { diff --git a/external/ag-website-shared/src/components/contact-form/ContactForm.tsx b/external/ag-website-shared/src/components/contact-form/ContactForm.tsx index f2684ede281..f23f810d5e2 100644 --- a/external/ag-website-shared/src/components/contact-form/ContactForm.tsx +++ b/external/ag-website-shared/src/components/contact-form/ContactForm.tsx @@ -1,10 +1,15 @@ +import { ConsentCheckbox } from '@ag-website-shared/components/consent-fields/ConsentCheckbox'; +import { + CONSENT_LABELS, + DATA_PROCESSING_CONSENT_REQUIRED, +} from '@ag-website-shared/components/consent-fields/consentMessages'; import { initCaptcha } from '@ag-website-shared/components/contact-form/initCaptcha'; import { Icon } from '@ag-website-shared/components/icon/Icon'; -import { CONTACT_FORM_DATA, PRIVACY_POLICY_URL, RECAPTCHA_URL, STUDIO_FORM_DATA } from '@ag-website-shared/constants'; +import { CONSENT_FIELD_IDS, CONTACT_FORM_DATA, RECAPTCHA_URL, STUDIO_FORM_DATA } from '@ag-website-shared/constants'; import { LIBRARY } from '@constants'; import { getIsDev, getIsProduction } from '@utils/env'; import classnames from 'classnames'; -import type { FunctionComponent } from 'react'; +import type { ChangeEvent, FunctionComponent } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -25,6 +30,10 @@ const { captchaSettingsKeyName, } = getIsProduction() ? contactFormData.production : contactFormData.default; +const { dataProcessingConsentId, marketingEmailConsentId, emailTrackingConsentId, franceOrItalyId } = getIsProduction() + ? CONSENT_FIELD_IDS.production + : CONSENT_FIELD_IDS.default; + const isDev = getIsDev(); const ENQUIRY_TYPE_OPTIONS = [ @@ -82,14 +91,30 @@ export const ContactForm: FunctionComponent = ({ const [isDisabled, setIsDisabled] = useState(false); const [captchaError, setCaptchaError] = useState(false); + const [isFranceOrItaly, setIsFranceOrItaly] = useState(false); + const { register, handleSubmit, + setValue, formState: { errors }, } = useForm({ mode: 'onBlur', }); + // Email tracking consent only applies to France and Italy, so hiding it must also + // withdraw it — never submit a consent the visitor can no longer see + const handleFranceOrItalyChange = useCallback( + (e: ChangeEvent) => { + const { checked } = e.target; + setIsFranceOrItaly(checked); + if (!checked) { + setValue(emailTrackingConsentId, ''); + } + }, + [setValue] + ); + useEffect(() => { const searchParams = new URLSearchParams(window.location.search); const hasDebugFlag = searchParams.has('debug'); @@ -246,6 +271,44 @@ export const ContactForm: FunctionComponent = ({ )} +
+ + + + + + + {isFranceOrItaly && ( + + )} +
+
@@ -258,9 +321,6 @@ export const ContactForm: FunctionComponent = ({ type="submit" value={submitLabel || 'Send us a message'} /> -

- By submitting this form you agree to our Privacy Policy. -

For technical support, visit our{' '} View as Markdown diff --git a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceForm.module.scss b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceForm.module.scss index cf0328a763c..cf78ebb5e07 100644 --- a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceForm.module.scss +++ b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceForm.module.scss @@ -73,6 +73,13 @@ $trial-licence-small: 680px; } } +.consents { + display: flex; + flex-direction: column; + gap: $spacing-size-2; + margin-bottom: $spacing-size-4; +} + .actions { display: flex; flex-direction: column; @@ -96,13 +103,30 @@ $trial-licence-small: 680px; } } -div p.privacyMessage { - font-size: var(--text-fs-xs); - line-height: var(--text-lh-xs); - color: color-mix(in srgb, var(--color-text-secondary), var(--color-bg-primary) 30%); +.submit { + display: inline-flex; + align-items: center; + justify-content: center; + gap: $spacing-size-2; +} + +.submitSpinner { + flex-shrink: 0; + width: 1em; + height: 1em; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: trial-form-spinner 0.7s linear infinite; + + @media (prefers-reduced-motion: reduce) { + animation-duration: 2.4s; + } +} - a { - font-weight: var(--text-regular); +@keyframes trial-form-spinner { + to { + transform: rotate(360deg); } } diff --git a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceForm.tsx b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceForm.tsx index 6b284c51c03..f2fc8d4eab3 100644 --- a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceForm.tsx +++ b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceForm.tsx @@ -1,20 +1,455 @@ -import { DEFAULT_TRIAL_LICENSE_FORM, type TrialLicenceFormType } from '@constants'; -import type { FunctionComponent } from 'react'; +import { ConsentCheckbox } from '@ag-website-shared/components/consent-fields/ConsentCheckbox'; +import { + CONSENT_LABELS, + DATA_PROCESSING_CONSENT_REQUIRED, +} from '@ag-website-shared/components/consent-fields/consentMessages'; +import { Icon } from '@ag-website-shared/components/icon/Icon'; +import { TRIAL_LICENCE_FORM_URL, ZI_FORM_ID } from '@constants'; +import { trackTrialLicenseFormError, trackTrialLicenseFormSuccess } from '@utils/analytics'; +import classnames from 'classnames'; +import { useCallback, useState } from 'react'; +import type { ChangeEventHandler, FormEventHandler, FunctionComponent } from 'react'; -import { TrialLicenceFormAllFields } from './TrialLicenceFormAllFields'; -import { TrialLicenceFormEmailOnly } from './TrialLicenceFormEmailOnly'; -import { TrialLicenceFormOriginal } from './TrialLicenceFormOriginal'; +import { MESSAGES } from './Messages'; +import styles from './TrialLicenceForm.module.scss'; interface Props { - type?: TrialLicenceFormType; + submitUrl?: string; } -export const TrialLicenceForm: FunctionComponent = ({ type = DEFAULT_TRIAL_LICENSE_FORM }: Props) => { - if (type === 'emailOnly') { - return ; - } else if (type === 'allFields') { - return ; - } else { - return ; +type TrialFormState = 'success' | 'error' | 'loading' | 'idle'; + +const getFormErrorMessage = (message: string) => { + let errorMessage = MESSAGES.formErrorDefault; + + if (message === 'invalid arguments provided') { + errorMessage = MESSAGES.formErrorInvalidArguments; + } else if (message.includes('INVALID_EMAIL_ADDRESS')) { + // eg, "Error: Unable to create a lead for a trial LK for email @bc.com. Error: Insert failed. First exception on row 0; first error: INVALID_EMAIL_ADDRESS, Email: invalid email address: @bc.com: [Email]" + errorMessage = MESSAGES.formErrorInvalidEmail; + } else if (message.includes('Duplicate email')) { + // eg, "Error: Unable to create a lead for a trial LK for email something@somewhere.com. Error: Duplicate email" + errorMessage = MESSAGES.formErrorDuplicateEmail; } + + return errorMessage; +}; + +const isEmailValid = (email: string) => { + const emailPattern = /^([a-zA-Z0-9._-]|\+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/; + return emailPattern.test(email); +}; + +const validateEmail = (email: string) => { + let validation = ''; + + if (!email) { + validation = MESSAGES.validationEmailRequired; + } else if (!isEmailValid(email)) { + validation = MESSAGES.validationEmailInvalid; + } + + return validation; +}; + +const validateRequired = (value: string) => { + let validation = ''; + if (value === '') { + validation = MESSAGES.validationRequiredField; + } + + return validation; +}; + +function useEmailValidation(initialValue: string = '') { + const [email, setEmail] = useState(initialValue); + const [emailError, setEmailError] = useState(validateEmail(initialValue)); + + const handleEmailChange: ChangeEventHandler = useCallback((e) => { + const value = e.target?.value; + setEmail(value); + setEmailError(validateEmail(value)); + }, []); + + return { + emailError, + email, + handleEmailChange, + }; +} + +function useRequiredValidation(initialValue: string = '') { + const [value, setValue] = useState(initialValue); + const [valueError, setValueError] = useState(validateRequired(initialValue)); + + const handleValueChange: ChangeEventHandler = useCallback((e) => { + const value = e.target?.value; + setValue(value); + setValueError(validateRequired(value)); + }, []); + + return { + valueError, + value, + handleValueChange, + }; +} + +function useCheckbox(initialValue: boolean = false) { + const [checked, setChecked] = useState(initialValue); + + const handleCheckedChange: ChangeEventHandler = useCallback((e) => { + setChecked(e.target.checked); + }, []); + + return { + checked, + setChecked, + handleCheckedChange, + }; +} + +async function submitTrialLicenceFormData({ + submitUrl = TRIAL_LICENCE_FORM_URL, + firstName, + lastName, + email, + company, + dataProcessingConsent, + marketingEmailConsent, + emailTrackingConsent, + franceOrItaly, +}: { + submitUrl?: string; + firstName: string; + lastName: string; + email: string; + company: string; + dataProcessingConsent: boolean; + marketingEmailConsent: boolean; + emailTrackingConsent: boolean; + franceOrItaly: boolean; +}) { + const response = await fetch(submitUrl, { + method: 'POST', + body: JSON.stringify({ + data: { + firstName, + lastName, + email, + company, + dataProcessingConsent, + marketingEmailConsent, + emailTrackingConsent, + franceOrItaly, + }, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + const json = await response.json(); + + return json; +} + +function useTrialForm({ submitUrl }: Props) { + const [formState, setFormState] = useState('idle'); + const [formError, setFormError] = useState(''); + const [wasValidated, setWasValidated] = useState(false); + const { emailError: validatedEmailError, email, handleEmailChange } = useEmailValidation(); + const emailError = wasValidated && validatedEmailError ? validatedEmailError : ''; + + const { + value: firstName, + valueError: validatedFirstNameError, + handleValueChange: handleFirstNameChange, + } = useRequiredValidation(); + const firstNameError = wasValidated && validatedFirstNameError ? validatedFirstNameError : ''; + + const { + value: lastName, + valueError: validatedLastNameError, + handleValueChange: handleLastNameChange, + } = useRequiredValidation(); + const lastNameError = wasValidated && validatedLastNameError ? validatedLastNameError : ''; + + const { checked: dataProcessingConsent, handleCheckedChange: handleDataProcessingConsentChange } = useCheckbox(); + const dataProcessingConsentError = wasValidated && !dataProcessingConsent ? DATA_PROCESSING_CONSENT_REQUIRED : ''; + + const { checked: marketingEmailConsent, handleCheckedChange: handleMarketingEmailConsentChange } = useCheckbox(); + + const { + checked: emailTrackingConsent, + setChecked: setEmailTrackingConsent, + handleCheckedChange: handleEmailTrackingConsentChange, + } = useCheckbox(); + + const { checked: isFranceOrItaly, setChecked: setIsFranceOrItaly } = useCheckbox(); + + // Email tracking consent only applies to France and Italy, so hiding it must also + // withdraw it — never submit a consent the visitor can no longer see + const handleFranceOrItalyChange: ChangeEventHandler = useCallback((e) => { + const { checked } = e.target; + setIsFranceOrItaly(checked); + if (!checked) { + setEmailTrackingConsent(false); + } + }, []); + + const handleFormSubmit: FormEventHandler = useCallback( + async (e) => { + e.preventDefault(); + setWasValidated(true); + + if (validatedEmailError || validatedFirstNameError || validatedLastNameError || !dataProcessingConsent) { + setFormState('error'); + return; + } + + setFormError(''); + setFormState('loading'); + + const currentPage = window.location.pathname; + + try { + const company = (document.getElementById('company') as HTMLInputElement)?.value || ''; + const response = await submitTrialLicenceFormData({ + submitUrl, + firstName, + lastName, + email, + company, + dataProcessingConsent, + marketingEmailConsent, + emailTrackingConsent, + franceOrItaly: isFranceOrItaly, + }); + + if (response.error) { + setFormState('error'); + const errorMessage = getFormErrorMessage(response.error.message); + setFormError(errorMessage); + trackTrialLicenseFormError({ + error: response.error.message, + errorType: 'api_error', + page: currentPage, + }); + } else { + setFormState('success'); + trackTrialLicenseFormSuccess({ + page: currentPage, + }); + } + } catch (e) { + console.error(e); + const errorMessage = MESSAGES.formErrorDefault; + setFormError(errorMessage); + trackTrialLicenseFormError({ + error: e instanceof Error ? e.message : 'Unknown error', + errorType: 'system_error', + page: currentPage, + }); + setFormState('error'); + } + }, + [ + validatedEmailError, + validatedFirstNameError, + validatedLastNameError, + firstName, + lastName, + email, + dataProcessingConsent, + marketingEmailConsent, + emailTrackingConsent, + isFranceOrItaly, + ] + ); + + return { + formState, + formError, + emailError, + email, + handleEmailChange, + firstName, + firstNameError, + handleFirstNameChange, + lastName, + lastNameError, + handleLastNameChange, + dataProcessingConsent, + dataProcessingConsentError, + handleDataProcessingConsentChange, + marketingEmailConsent, + handleMarketingEmailConsentChange, + emailTrackingConsent, + handleEmailTrackingConsentChange, + isFranceOrItaly, + handleFranceOrItalyChange, + handleFormSubmit, + }; +} + +export const TrialLicenceForm: FunctionComponent = ({ submitUrl }: Props) => { + const { + formState, + formError, + emailError, + email, + handleEmailChange, + firstName, + firstNameError, + handleFirstNameChange, + lastName, + lastNameError, + handleLastNameChange, + dataProcessingConsent, + dataProcessingConsentError, + handleDataProcessingConsentChange, + marketingEmailConsent, + handleMarketingEmailConsentChange, + emailTrackingConsent, + handleEmailTrackingConsentChange, + isFranceOrItaly, + handleFranceOrItalyChange, + handleFormSubmit, + } = useTrialForm({ submitUrl }); + const hasFormError = Boolean(emailError || firstNameError || lastNameError || dataProcessingConsentError); + + return ( +

+
+
+ + +
+ +
+ + + + + +

{emailError ? emailError : 'Email required'}

+
+ +
+ + + +

First name required

+
+ +
+ + + +

Last name required

+
+
+ +
+ + + + + + + {isFranceOrItaly && ( + + )} +
+ +
+ + + {formState === 'success' && ( +

+ + + Thank you. Please check your inbox to validate your email and receive your{' '} + Enterprise Bundle trial licence. + +

+ )} + + {formError && ( +

+ + {formError} +

+ )} +
+ + ); }; diff --git a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormAllFields.tsx b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormAllFields.tsx deleted file mode 100644 index 26c3a90a29f..00000000000 --- a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormAllFields.tsx +++ /dev/null @@ -1,323 +0,0 @@ -import { Icon } from '@ag-website-shared/components/icon/Icon'; -import { PRIVACY_POLICY_URL } from '@ag-website-shared/constants'; -import { TRIAL_LICENCE_FORM_URL, ZI_FORM_ID } from '@constants'; -import { trackTrialLicenseFormError, trackTrialLicenseFormSuccess } from '@utils/analytics'; -import classnames from 'classnames'; -import { useCallback, useState } from 'react'; -import type { ChangeEventHandler, FormEventHandler, FunctionComponent } from 'react'; - -import { MESSAGES } from './Messages'; -import styles from './TrialLicenceForm.module.scss'; - -interface Props { - submitUrl?: string; -} - -type TrialFormState = 'success' | 'error' | 'loading' | 'idle'; - -const getFormErrorMessage = (message: string) => { - let errorMessage = MESSAGES.formErrorDefault; - - if (message === 'invalid arguments provided') { - errorMessage = MESSAGES.formErrorInvalidArguments; - } else if (message.includes('INVALID_EMAIL_ADDRESS')) { - // eg, "Error: Unable to create a lead for a trial LK for email @bc.com. Error: Insert failed. First exception on row 0; first error: INVALID_EMAIL_ADDRESS, Email: invalid email address: @bc.com: [Email]" - errorMessage = MESSAGES.formErrorInvalidEmail; - } else if (message.includes('Duplicate email')) { - // eg, "Error: Unable to create a lead for a trial LK for email something@somewhere.com. Error: Duplicate email" - errorMessage = MESSAGES.formErrorDuplicateEmail; - } - - return errorMessage; -}; - -const isEmailValid = (email: string) => { - const emailPattern = /^([a-zA-Z0-9._-]|\+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/; - return emailPattern.test(email); -}; - -const validateEmail = (email: string) => { - let validation = ''; - - if (!email) { - validation = MESSAGES.validationEmailRequired; - } else if (!isEmailValid(email)) { - validation = MESSAGES.validationEmailInvalid; - } - - return validation; -}; - -const validateRequired = (value: string) => { - let validation = ''; - if (value === '') { - validation = MESSAGES.validationRequiredField; - } - - return validation; -}; - -function useEmailValidation(initialValue: string = '') { - const [email, setEmail] = useState(initialValue); - const [emailError, setEmailError] = useState(validateEmail(initialValue)); - - const handleEmailChange: ChangeEventHandler = useCallback((e) => { - const value = e.target?.value; - setEmail(value); - setEmailError(validateEmail(value)); - }, []); - - return { - emailError, - email, - handleEmailChange, - }; -} - -function useRequiredValidation(initialValue: string = '') { - const [value, setValue] = useState(initialValue); - const [valueError, setValueError] = useState(validateRequired(initialValue)); - - const handleValueChange: ChangeEventHandler = useCallback((e) => { - const value = e.target?.value; - setValue(value); - setValueError(validateRequired(value)); - }, []); - - return { - valueError, - value, - handleValueChange, - }; -} - -async function submitTrialLicenceFormData({ - submitUrl = TRIAL_LICENCE_FORM_URL, - firstName, - lastName, - email, - company, -}: { - submitUrl?: string; - firstName: string; - lastName: string; - email: string; - company: string; -}) { - const response = await fetch(submitUrl, { - method: 'POST', - body: JSON.stringify({ data: { firstName, lastName, email, company } }), - headers: { - 'Content-Type': 'application/json', - }, - }); - const json = await response.json(); - - return json; -} - -function useTrialForm({ submitUrl }: Props) { - const [formState, setFormState] = useState('idle'); - const [formError, setFormError] = useState(''); - const [wasValidated, setWasValidated] = useState(false); - const { emailError: validatedEmailError, email, handleEmailChange } = useEmailValidation(); - const emailError = wasValidated && validatedEmailError ? validatedEmailError : ''; - - const { - value: firstName, - valueError: validatedFirstNameError, - handleValueChange: handleFirstNameChange, - } = useRequiredValidation(); - const firstNameError = wasValidated && validatedFirstNameError ? validatedFirstNameError : ''; - - const { - value: lastName, - valueError: validatedLastNameError, - handleValueChange: handleLastNameChange, - } = useRequiredValidation(); - const lastNameError = wasValidated && validatedLastNameError ? validatedLastNameError : ''; - - const handleFormSubmit: FormEventHandler = useCallback( - async (e) => { - e.preventDefault(); - setWasValidated(true); - - if (validatedEmailError || validatedFirstNameError || validatedLastNameError) { - setFormState('error'); - return; - } - - setFormError(''); - setFormState('loading'); - - const currentPage = window.location.pathname; - - try { - const company = (document.getElementById('company') as HTMLInputElement)?.value || ''; - const response = await submitTrialLicenceFormData({ submitUrl, firstName, lastName, email, company }); - - if (response.error) { - setFormState('error'); - const errorMessage = getFormErrorMessage(response.error.message); - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: response.error.message, - errorType: 'api_error', - page: currentPage, - }); - } else { - setFormState('success'); - trackTrialLicenseFormSuccess({ - page: currentPage, - }); - } - } catch (e) { - console.error(e); - const errorMessage = MESSAGES.formErrorDefault; - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: e instanceof Error ? e.message : 'Unknown error', - errorType: 'system_error', - page: currentPage, - }); - setFormState('error'); - } - }, - [validatedEmailError, validatedFirstNameError, validatedLastNameError, firstName, lastName, email] - ); - - return { - formState, - formError, - emailError, - email, - handleEmailChange, - firstName, - firstNameError, - handleFirstNameChange, - lastName, - lastNameError, - handleLastNameChange, - handleFormSubmit, - }; -} - -export const TrialLicenceFormAllFields: FunctionComponent = ({ submitUrl }: Props) => { - const { - formState, - formError, - emailError, - email, - handleEmailChange, - firstName, - firstNameError, - handleFirstNameChange, - lastName, - lastNameError, - handleLastNameChange, - handleFormSubmit, - } = useTrialForm({ submitUrl }); - const hasFormError = Boolean(emailError || firstNameError || lastNameError); - - return ( -
-
-
- - -
- -
- - - - - -

- {emailError ? emailError : 'Email required'} -

-
- -
- - - -

- First name required -

-
- -
- - - -

Last name required

-
-
- -
- - -

- By clicking "Request trial licence" you agree to our Privacy Policy - , and to be contacted by a member of our team -

- - {formState === 'success' && ( -

- - - Thank you. Please check your inbox to validate your email and receive your{' '} - Enterprise Bundle trial licence. - -

- )} - - {formError && ( -

- - {formError} -

- )} -
-
- ); -}; diff --git a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormEmailOnly.tsx b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormEmailOnly.tsx deleted file mode 100644 index 10ecb8782b3..00000000000 --- a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormEmailOnly.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { Icon } from '@ag-website-shared/components/icon/Icon'; -import { PRIVACY_POLICY_URL } from '@ag-website-shared/constants'; -import { TRIAL_LICENCE_FORM_URL } from '@constants'; -import { ZI_FORM_ID } from '@constants'; -import { trackTrialLicenseFormError, trackTrialLicenseFormSuccess } from '@utils/analytics'; -import classnames from 'classnames'; -import { useCallback, useState } from 'react'; -import type { ChangeEventHandler, FormEventHandler, FunctionComponent, ReactElement } from 'react'; - -import { MESSAGES } from './Messages'; -import styles from './TrialLicenceForm.module.scss'; - -interface Props { - submitUrl?: string; -} - -type TrialFormState = 'success' | 'error' | 'loading' | 'idle'; - -const getFormErrorMessage = (message: string) => { - let errorMessage = MESSAGES.formErrorDefault; - - if (message === 'invalid arguments provided') { - errorMessage = MESSAGES.formErrorInvalidArguments; - } else if (message.includes('INVALID_EMAIL_ADDRESS')) { - // eg, "Error: Unable to create a lead for a trial LK for email @bc.com. Error: Insert failed. First exception on row 0; first error: INVALID_EMAIL_ADDRESS, Email: invalid email address: @bc.com: [Email]" - errorMessage = MESSAGES.formErrorInvalidEmail; - } else if (message.includes('Duplicate email')) { - // eg, "Error: Unable to create a lead for a trial LK for email something@somewhere.com. Error: Duplicate email" - errorMessage = MESSAGES.formErrorDuplicateEmail; - } - - return errorMessage; -}; - -const isEmailValid = (email: string) => { - const emailPattern = /^([a-zA-Z0-9._-]|\+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/; - return emailPattern.test(email); -}; - -const validateEmail = (email: string) => { - let validation = ''; - - if (!email) { - validation = MESSAGES.validationEmailRequired; - } else if (!isEmailValid(email)) { - validation = MESSAGES.validationEmailInvalid; - } - - return validation; -}; - -function useEmailValidation(initialValue: string = '') { - const [email, setEmail] = useState(initialValue); - const [emailError, setEmailError] = useState(validateEmail(initialValue)); - - const handleEmailChange: ChangeEventHandler = useCallback((e) => { - const value = e.target?.value; - setEmail(value); - setEmailError(validateEmail(value)); - }, []); - - return { - emailError, - email, - handleEmailChange, - }; -} - -async function submitTrialLicenceFormData({ - submitUrl = TRIAL_LICENCE_FORM_URL, - firstName, - lastName, - email, - company, -}: { - submitUrl?: string; - firstName: string; - lastName: string; - email: string; - company: string; -}) { - const response = await fetch(submitUrl, { - method: 'POST', - body: JSON.stringify({ data: { firstName, lastName, email, company } }), - headers: { - 'Content-Type': 'application/json', - }, - }); - const json = await response.json(); - - return json; -} - -function useTrialForm({ submitUrl }: Props) { - const [formState, setFormState] = useState('idle'); - const [formError, setFormError] = useState(''); - const [wasValidated, setWasValidated] = useState(false); - const { emailError: validatedEmailError, email, handleEmailChange } = useEmailValidation(); - const emailError = wasValidated && validatedEmailError ? validatedEmailError : ''; - - const handleFormSubmit: FormEventHandler = useCallback( - async (e) => { - e.preventDefault(); - setWasValidated(true); - - if (validatedEmailError) { - setFormState('error'); - return; - } - - setFormError(''); - setFormState('loading'); - - const currentPage = window.location.pathname; - - try { - const firstName = (document.getElementById('first-name') as HTMLInputElement)?.value || ''; - const lastName = (document.getElementById('last-name') as HTMLInputElement)?.value || ''; - const company = (document.getElementById('company') as HTMLInputElement)?.value || ''; - const response = await submitTrialLicenceFormData({ submitUrl, firstName, lastName, email, company }); - - if (response.error) { - setFormState('error'); - const errorMessage = getFormErrorMessage(response.error.message); - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: response.error.message, - errorType: 'api_error', - page: currentPage, - }); - } else { - setFormState('success'); - trackTrialLicenseFormSuccess({ - page: currentPage, - }); - } - } catch (e) { - console.error(e); - const errorMessage = MESSAGES.formErrorDefault; - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: e instanceof Error ? e.message : 'Unknown error', - errorType: 'system_error', - page: currentPage, - }); - setFormState('error'); - } - }, - [validatedEmailError, email] - ); - - return { - formState, - formError, - emailError, - email, - handleEmailChange, - handleFormSubmit, - }; -} - -export const TrialLicenceFormEmailOnly: FunctionComponent = ({ submitUrl }: Props) => { - const { formState, formError, emailError, email, handleEmailChange, handleFormSubmit } = useTrialForm({ - submitUrl, - }); - const hasFormError = Boolean(emailError); - - return ( -
-
- - - - -
- - - - - -

- {emailError ? emailError : 'Email required'} -

-
-
-
- - -

- By clicking "Request trial licence" you agree to our Privacy Policy - , and to be contacted by a member of our team -

- - {formState === 'success' && ( -

- - - Thank you. Please check your inbox to validate your email and receive your{' '} - Enterprise Bundle trial licence. - -

- )} - - {formError && ( -

- - {formError} -

- )} -
-
- ); -}; diff --git a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormOriginal.tsx b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormOriginal.tsx deleted file mode 100644 index e343f6bce99..00000000000 --- a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormOriginal.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { Icon } from '@ag-website-shared/components/icon/Icon'; -import { PRIVACY_POLICY_URL } from '@ag-website-shared/constants'; -import { TRIAL_LICENCE_FORM_URL } from '@constants'; -import { trackTrialLicenseFormError, trackTrialLicenseFormSuccess } from '@utils/analytics'; -import classnames from 'classnames'; -import { useCallback, useState } from 'react'; -import type { ChangeEventHandler, FormEventHandler, FunctionComponent } from 'react'; - -import { MESSAGES } from './Messages'; -import styles from './TrialLicenceForm.module.scss'; - -interface Props { - submitUrl?: string; -} - -type TrialFormState = 'success' | 'error' | 'loading' | 'idle'; - -const getFormErrorMessage = (message: string) => { - let errorMessage = MESSAGES.formErrorDefault; - - if (message === 'invalid arguments provided') { - errorMessage = MESSAGES.formErrorInvalidArguments; - } else if (message.includes('INVALID_EMAIL_ADDRESS')) { - // eg, "Error: Unable to create a lead for a trial LK for email @bc.com. Error: Insert failed. First exception on row 0; first error: INVALID_EMAIL_ADDRESS, Email: invalid email address: @bc.com: [Email]" - errorMessage = MESSAGES.formErrorInvalidEmail; - } else if (message.includes('Duplicate email')) { - // eg, "Error: Unable to create a lead for a trial LK for email something@somewhere.com. Error: Duplicate email" - errorMessage = MESSAGES.formErrorDuplicateEmail; - } - - return errorMessage; -}; - -const isEmailValid = (email: string) => { - const emailPattern = /^([a-zA-Z0-9._-]|\+)+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,63}$/; - return emailPattern.test(email); -}; - -const validateEmail = (email: string) => { - let validation = ''; - - if (!email) { - validation = MESSAGES.validationEmailRequired; - } else if (!isEmailValid(email)) { - validation = MESSAGES.validationEmailInvalid; - } - - return validation; -}; - -const validateRequired = (value: string) => { - let validation = ''; - if (value === '') { - validation = MESSAGES.validationRequiredField; - } - - return validation; -}; - -function useEmailValidation(initialValue: string = '') { - const [email, setEmail] = useState(initialValue); - const [emailError, setEmailError] = useState(validateEmail(initialValue)); - - const handleEmailChange: ChangeEventHandler = useCallback((e) => { - const value = e.target?.value; - setEmail(value); - setEmailError(validateEmail(value)); - }, []); - - return { - emailError, - email, - handleEmailChange, - }; -} - -function useRequiredValidation(initialValue: string = '') { - const [value, setValue] = useState(initialValue); - const [valueError, setValueError] = useState(validateRequired(initialValue)); - - const handleValueChange: ChangeEventHandler = useCallback((e) => { - const value = e.target?.value; - setValue(value); - setValueError(validateRequired(value)); - }, []); - - return { - valueError, - value, - handleValueChange, - }; -} - -async function submitTrialLicenceFormData({ - submitUrl = TRIAL_LICENCE_FORM_URL, - firstName, - lastName, - email, -}: { - submitUrl?: string; - firstName: string; - lastName: string; - email: string; -}) { - const response = await fetch(submitUrl, { - method: 'POST', - body: JSON.stringify({ data: { firstName, lastName, email } }), - headers: { - 'Content-Type': 'application/json', - }, - }); - const json = await response.json(); - - return json; -} - -function useTrialForm({ submitUrl }: Props) { - const [formState, setFormState] = useState('idle'); - const [formError, setFormError] = useState(''); - const [wasValidated, setWasValidated] = useState(false); - const { emailError: validatedEmailError, email, handleEmailChange } = useEmailValidation(); - const emailError = wasValidated && validatedEmailError ? validatedEmailError : ''; - - const { - value: firstName, - valueError: validatedFirstNameError, - handleValueChange: handleFirstNameChange, - } = useRequiredValidation(); - const firstNameError = wasValidated && validatedFirstNameError ? validatedFirstNameError : ''; - - const { - value: lastName, - valueError: validatedLastNameError, - handleValueChange: handleLastNameChange, - } = useRequiredValidation(); - const lastNameError = wasValidated && validatedLastNameError ? validatedLastNameError : ''; - - const handleFormSubmit: FormEventHandler = useCallback( - async (e) => { - e.preventDefault(); - setWasValidated(true); - - if (validatedEmailError || validatedFirstNameError || validatedLastNameError) { - setFormState('error'); - return; - } - - setFormError(''); - setFormState('loading'); - - const currentPage = window.location.pathname; - - try { - const response = await submitTrialLicenceFormData({ submitUrl, firstName, lastName, email }); - - if (response.error) { - setFormState('error'); - const errorMessage = getFormErrorMessage(response.error.message); - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: response.error.message, - errorType: 'api_error', - page: currentPage, - }); - } else { - setFormState('success'); - trackTrialLicenseFormSuccess({ - page: currentPage, - }); - } - } catch (e) { - console.error(e); - const errorMessage = MESSAGES.formErrorDefault; - setFormError(errorMessage); - trackTrialLicenseFormError({ - error: e instanceof Error ? e.message : 'Unknown error', - errorType: 'system_error', - page: currentPage, - }); - setFormState('error'); - } - }, - [validatedEmailError, validatedFirstNameError, validatedLastNameError, firstName, lastName, email] - ); - - return { - formState, - formError, - emailError, - email, - handleEmailChange, - firstName, - firstNameError, - handleFirstNameChange, - lastName, - lastNameError, - handleLastNameChange, - handleFormSubmit, - }; -} - -export const TrialLicenceFormOriginal: FunctionComponent = ({ submitUrl }: Props) => { - const { - formState, - formError, - emailError, - email, - handleEmailChange, - firstName, - firstNameError, - handleFirstNameChange, - lastName, - lastNameError, - handleLastNameChange, - handleFormSubmit, - } = useTrialForm({ submitUrl }); - const hasFormError = Boolean(emailError || firstNameError || lastNameError); - - return ( -
-
-
- - - -

- First name required -

-
- -
- - - -

Last name required

-
- -
- - - - - -

- {emailError ? emailError : 'Email required'} -

-
-
- -
- - -

- By clicking "Request trial licence" you agree to our Privacy Policy - , and to be contacted by a member of our team -

- - {formState === 'success' && ( -

- - - Thank you. Please check your inbox to validate your email and receive your{' '} - Enterprise Bundle trial licence. - -

- )} - - {formError && ( -

- - {formError} -

- )} -
-
- ); -}; diff --git a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormStudio.tsx b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormStudio.tsx index fb506d56f35..d76f77d2e1e 100644 --- a/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormStudio.tsx +++ b/external/ag-website-shared/src/components/trial-licence-form/TrialLicenceFormStudio.tsx @@ -1,5 +1,4 @@ import { Icon } from '@ag-website-shared/components/icon/Icon'; -import { PRIVACY_POLICY_URL } from '@ag-website-shared/constants'; import { TRIAL_LICENCE_FORM_URL, ZI_FORM_ID } from '@constants'; import { trackTrialLicenseFormError, trackTrialLicenseFormSuccess } from '@utils/analytics'; import classnames from 'classnames'; @@ -236,16 +235,7 @@ export const TrialLicenceFormStudio: FunctionComponent = ({ submitUrl }: Props) required /> -

- First name required -

+

First name required

@@ -260,7 +250,7 @@ export const TrialLicenceFormStudio: FunctionComponent = ({ submitUrl }: Props) required /> -

Last name required

+

Last name required

@@ -278,9 +268,7 @@ export const TrialLicenceFormStudio: FunctionComponent = ({ submitUrl }: Props) onChange={handleEmailChange} /> -

- {emailError ? emailError : 'Email required'} -

+

{emailError ? emailError : 'Email required'}

@@ -289,14 +277,12 @@ export const TrialLicenceFormStudio: FunctionComponent = ({ submitUrl }: Props) className={styles.submit} type="submit" disabled={hasFormError || formState === 'loading' || formState === 'success'} + aria-busy={formState === 'loading'} > + {formState === 'loading' &&