├── .changeset
└── config.json
├── .editorconfig
├── .eslintrc.yml
├── .github
├── CODEOWNERS
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ └── config.yml
└── workflows
│ ├── ci.yaml
│ ├── dependency-review.yml
│ └── release.yml
├── .gitignore
├── .husky
└── pre-commit
├── .lintstagedrc.yml
├── .prettierrc.yml
├── CONTRIBUTING.md
├── README.md
├── examples
└── cra-demo
│ ├── .env.local.template
│ ├── .eslintrc.yml
│ ├── README.md
│ ├── package.json
│ ├── public
│ ├── favicon.ico
│ ├── index.html
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── robots.txt
│ ├── src
│ ├── App.tsx
│ ├── CardExample.tsx
│ ├── PaymentForm.tsx
│ ├── React19.tsx
│ ├── RecaptchaForm.tsx
│ ├── SimpleForm.tsx
│ ├── WithReactHookForm.tsx
│ ├── index.css
│ ├── index.tsx
│ └── react-app-env.d.ts
│ └── tsconfig.json
├── package.json
├── packages
├── formspree-core
│ ├── .babelrc.json
│ ├── .eslintrc.yml
│ ├── .lintstagedrc.yml
│ ├── CHANGELOG.md
│ ├── LICENSE
│ ├── README.md
│ ├── jest.config.js
│ ├── jest.setup.js
│ ├── package.json
│ ├── src
│ │ ├── base64.d.ts
│ │ ├── base64.js
│ │ ├── core.ts
│ │ ├── env.d.ts
│ │ ├── index.ts
│ │ ├── session.ts
│ │ ├── submission.ts
│ │ └── utils.ts
│ ├── test
│ │ ├── core.test.ts
│ │ ├── session.test.ts
│ │ ├── submission.test.ts
│ │ └── utils.test.ts
│ └── tsconfig.json
└── formspree-react
│ ├── .babelrc.json
│ ├── .eslintrc.yml
│ ├── .lintstagedrc.yml
│ ├── CHANGELOG.md
│ ├── LICENSE
│ ├── README.md
│ ├── jest.config.js
│ ├── jest.setup.js
│ ├── package.json
│ ├── src
│ ├── ValidationError.tsx
│ ├── context.tsx
│ ├── index.ts
│ ├── stripe.tsx
│ ├── types.ts
│ ├── useForm.ts
│ └── useSubmit.ts
│ ├── test
│ ├── ValidationError.test.tsx
│ ├── __snapshots__
│ │ └── ValidationError.test.tsx.snap
│ ├── context.test.tsx
│ ├── mockStripe.ts
│ ├── useForm.test.tsx
│ └── useSubmit.test.tsx
│ └── tsconfig.json
├── tsconfig.json
├── turbo.json
└── yarn.lock
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": false,
5 | "fixed": [],
6 | "linked": [],
7 | "access": "public",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": ["@formspree/cra-demo"]
11 | }
12 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # Stop the editor from looking for .editorconfig files in the parent directories
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | insert_final_newline = true
7 | end_of_line = lf
8 | indent_style = space
9 | indent_size = 2
10 | max_line_length = 80
11 |
--------------------------------------------------------------------------------
/.eslintrc.yml:
--------------------------------------------------------------------------------
1 | root: true
2 | env:
3 | browser: true
4 | jest/globals: true
5 | extends:
6 | - eslint:recommended
7 | - prettier # eslint-config-prettier
8 | - plugin:@typescript-eslint/recommended
9 | plugins:
10 | - jest # eslint-plugin-jest
11 | rules:
12 | '@typescript-eslint/ban-ts-comment': off # temporary disable while addressing existing violations
13 | '@typescript-eslint/consistent-type-exports': error
14 | '@typescript-eslint/consistent-type-imports': error
15 | '@typescript-eslint/no-empty-function': off
16 |
--------------------------------------------------------------------------------
/.github/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/about-codeowners/ for more info
2 | # Each line is a file pattern followed by one or more owners.
3 |
4 | # These owners will be the default owners for everything in the repo.
5 | * @formspree
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug/issue
3 | title: '[bug]
'
4 | body:
5 | - type: markdown
6 | attributes:
7 | value: |
8 | Thanks for taking the time to fill out this bug report! The more info you provide, the more we can help you.
9 | - type: checkboxes
10 | attributes:
11 | label: Is there an existing issue for this?
12 | description: Please search to see if an issue already exists for the bug you encountered.
13 | options:
14 | - label: I have searched the existing issues
15 | required: true
16 |
17 | - type: input
18 | attributes:
19 | label: Formspree React Version
20 | description: What version of Formspree React are you using?
21 | placeholder: 1.0.0
22 | validations:
23 | required: true
24 |
25 | - type: input
26 | attributes:
27 | label: Formspree Core Version
28 | description: What version of Formspree Core are you using?
29 | placeholder: 1.0.0
30 | validations:
31 | required: true
32 |
33 | - type: textarea
34 | attributes:
35 | label: Current Behavior
36 | description: A concise description of what you're experiencing.
37 | validations:
38 | required: false
39 |
40 | - type: textarea
41 | attributes:
42 | label: Expected Behavior
43 | description: A concise description of what you expected to happen.
44 | validations:
45 | required: false
46 |
47 | - type: textarea
48 | attributes:
49 | label: Steps To Reproduce
50 | description: Steps or code snippets to reproduce the behavior.
51 | validations:
52 | required: false
53 |
54 | - type: input
55 | attributes:
56 | label: Link to Minimal Reproducible Example (CodeSandbox, StackBlitz, etc.)
57 | description: |
58 | This makes investigating issues and helping you out significantly easier! For most issues, you will likely get asked to provide one so why not add one now :)
59 | placeholder: https://codesandbox.io
60 | validations:
61 | required: false
62 |
63 | - type: textarea
64 | attributes:
65 | label: Anything else?
66 | description: |
67 | Browser info? Screenshots? Anything that will give us more context about the issue you are encountering!
68 |
69 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
70 | validations:
71 | required: false
72 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: Open an issue
4 | url: https://github.com/formspree/formspree-react/issues/new
5 | about: Ask questions, suggest ideas or report your issue.
6 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yaml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: push
3 |
4 | jobs:
5 | test:
6 | name: Test
7 | runs-on: ubuntu-latest
8 | permissions:
9 | contents: read
10 | steps:
11 | - uses: actions/checkout@v4
12 | - uses: actions/setup-node@v4
13 | with:
14 | node-version: 20.x
15 | cache: yarn
16 | cache-dependency-path: yarn.lock
17 | - run: yarn install --frozen-lockfile
18 | # We need to build first in order to generate the type declarations
19 | # in @formspree/core.
20 | - run: yarn build --filter="./packages/*"
21 | - run: yarn typecheck --filter="./packages/*"
22 | - run: yarn test --filter="./packages/*"
23 |
--------------------------------------------------------------------------------
/.github/workflows/dependency-review.yml:
--------------------------------------------------------------------------------
1 | # Dependency Review Action
2 | #
3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
4 | #
5 | # Source repository: https://github.com/actions/dependency-review-action
6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
7 | name: Dependency Review
8 | on: [pull_request]
9 |
10 | permissions:
11 | contents: read
12 |
13 | jobs:
14 | dependency-review:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: Checkout Repository
18 | uses: actions/checkout@v4
19 | - name: Dependency Review
20 | uses: actions/dependency-review-action@v4
21 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | concurrency: ${{ github.workflow }}-${{ github.ref }}
9 |
10 | jobs:
11 | release:
12 | name: Release
13 |
14 | # IMPORTANT: prevent this action from running on forks
15 | if: ${{ github.repository_owner == 'formspree' }}
16 |
17 | runs-on: ubuntu-latest
18 | steps:
19 | - name: Checkout Repo
20 | uses: actions/checkout@v4
21 |
22 | - name: Setup Node.js 20.x
23 | uses: actions/setup-node@v4
24 | with:
25 | node-version: 20.x
26 | cache: yarn
27 | cache-dependency-path: yarn.lock
28 |
29 | - name: Install Dependencies
30 | run: yarn install --frozen-lockfile
31 |
32 | # This action will either create a new Release pull request with
33 | # all of the package versions and changelogs updated if there're
34 | # existing changesets or it will publish the packages to npm.
35 | - name: Create Release Pull Request or Publish to npm
36 | id: changesets
37 | uses: changesets/action@v1
38 | with:
39 | publish: yarn release
40 | env:
41 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | node_modules
3 | .pnp
4 | .pnp.js
5 |
6 | # testing
7 | coverage
8 |
9 | # CRA
10 | build
11 |
12 | # Rollup
13 | dist
14 |
15 | # misc
16 | .DS_Store
17 | *.pem
18 |
19 | # debug
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 |
24 | # local env files
25 | .env.local
26 | .env.development.local
27 | .env.test.local
28 | .env.production.local
29 | .env
30 |
31 | # turbo
32 | .turbo
33 |
34 | .vscode
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | yarn lint-staged
5 |
--------------------------------------------------------------------------------
/.lintstagedrc.yml:
--------------------------------------------------------------------------------
1 | package.json: yarn sort-package-json
2 | '*.{json,md,yml}': yarn prettier --write
3 |
--------------------------------------------------------------------------------
/.prettierrc.yml:
--------------------------------------------------------------------------------
1 | singleQuote: true
2 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Formspree Contribution Guide
2 |
3 | Thanks for your interest in contributing to Formspree! Please take a moment to review this document **before submitting a pull request.**
4 |
5 | If you want to contribute but aren't sure where to start, you can open a [new issue](https://github.com/formspree/formspree-react/issues).
6 |
7 | ## Prerequisites
8 |
9 | This project uses [`yarn v1`](https://yarnpkg.com/) as a package manager.
10 |
11 | ## Setting up your local repo
12 |
13 | Run the following commands from the root formspree-js directory:
14 |
15 | ```sh
16 | yarn
17 | yarn build # generate the artifact needed for depedendent packages
18 | ```
19 |
20 | ## Development environment
21 |
22 | To play around with code while making changes, you can run the local development environment:
23 |
24 | ```sh
25 | yarn dev
26 | ```
27 |
28 | This will run an example app ([`examples/cra-demo`](../examples/cra-demo)) on [localhost:3000](http://localhost:3000) that uses create-react-app.
29 |
30 | ## Development commands
31 |
32 | To run tests, typecheck on all packages:
33 |
34 | ```sh
35 | yarn test
36 | yarn typecheck
37 | ```
38 |
39 | ## Opening a Pull Request
40 |
41 | When opening a pull request, include a changeset with your pull request:
42 |
43 | ```sh
44 | yarn changeset
45 | ```
46 |
47 | The changeset files will be committed to main with the pull request. They will later be used for releasing a new version of a package.
48 |
49 | _Note: non-packages (examples/\*) do not need changesets._
50 |
51 | ## Releasing a new version
52 |
53 | _Note: Only core maintainers can release new versions of formpree-js packages._
54 |
55 | When pull requests are merged to `main`, a `release` Github Actions job will automatically generate a Version Package pull request, either creating a new one or updating an existing one.
56 |
57 | When we are ready to publish a new version for one or more packages, simply approve the Version Package pull request and merge it to `main`.
58 |
59 | Once the pull request is merged, the `release` GitHub Actions job will automatically tag and push the new versions of the affected packages and publish them to npm.
60 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Formspree JS
2 |
3 | A monorepo containing libraries for seamless form integration with [Formspree](https://formspree.io/) via Javascript and/or React.
4 |
5 | ## Installation
6 |
7 | The core and react packages can be installed using your package manager of choice.
8 |
9 | ### `@formspree/core`
10 |
11 | ```sh
12 | npm install @formspree/core
13 |
14 | yarn add @formspree/core
15 |
16 | pnpm add @formspree/core
17 | ```
18 |
19 | ### `@formspree/react`
20 |
21 | **Prerequisites**
22 |
23 | - React 16.8 or higher.
24 |
25 | ```sh
26 | npm install @formspree/react
27 |
28 | yarn add @formspree/react
29 |
30 | pnpm add @formspree/react
31 | ```
32 |
33 | _Note: `@formspree/core` is a dependency of `@formspree/react`, so you don't need to install `@formspree/core` separately._
34 |
35 | ## Help and Support
36 |
37 | For help and support please see [the Formspree React docs](https://help.formspree.io/hc/en-us/articles/360055613373).
38 |
39 | ## Contributing
40 |
41 | Please follow our [contributing guidelines](./CONTRIBUTING.md).
42 |
--------------------------------------------------------------------------------
/examples/cra-demo/.env.local.template:
--------------------------------------------------------------------------------
1 | REACT_APP_SIMPLE_FORM_ID=
2 | REACT_APP_RECAPTCHA_FORM_ID=
3 | REACT_APP_RECAPTCHA_KEY=
4 | REACT_APP_PAYMENT_FORM_ID=
5 | REACT_APP_STRIPE_PUBLISHABLE_KEY=
--------------------------------------------------------------------------------
/examples/cra-demo/.eslintrc.yml:
--------------------------------------------------------------------------------
1 | root: true
2 | extends:
3 | - '../../.eslintrc.yml'
4 | ignorePatterns:
5 | - jest.config.js
6 | - jest.setup.js
7 | - dist/
8 | parserOptions:
9 | project: './tsconfig.json'
10 |
--------------------------------------------------------------------------------
/examples/cra-demo/README.md:
--------------------------------------------------------------------------------
1 | # Formspree demo
2 |
3 | Quick simple demo with both a simple form and a payment form powered by Stripe
4 |
5 | ## Getting started
6 |
7 | 1. Create a Formspree account
8 | 2. Create 2 forms
9 | 3. Enable Stripe plugin on one of the forms
10 | 4. Run `cp .env.local.template .env.local`
11 | 5. Place your forms IDs and your Stripe publishable key that you can get from your Stripe plugin on Formspree plugins dashboard
12 | 6. Install dependencies
13 |
14 | ```bash
15 | yarn
16 | ```
17 |
18 | 7. Start the Vite project
19 |
20 | ```bash
21 | yarn start
22 | ```
23 |
24 | Happy coding!
25 |
--------------------------------------------------------------------------------
/examples/cra-demo/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@formspree/cra-demo",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "react-scripts build",
7 | "clean": "rm -rf build && rm -rf node_modules",
8 | "dev": "react-scripts start",
9 | "eject": "react-scripts eject",
10 | "start": "react-scripts start"
11 | },
12 | "browserslist": {
13 | "production": [
14 | ">0.2%",
15 | "not dead",
16 | "not op_mini all"
17 | ],
18 | "development": [
19 | "last 1 chrome version",
20 | "last 1 firefox version",
21 | "last 1 safari version"
22 | ]
23 | },
24 | "dependencies": {
25 | "@formspree/react": "*",
26 | "react": "^19.0.0",
27 | "react-copy-to-clipboard": "^5.1.0",
28 | "react-dom": "^19.0.0",
29 | "react-google-recaptcha-v3": "^1.10.1",
30 | "react-hook-form": "^7.54.2",
31 | "react-scripts": "5.0.1",
32 | "web-vitals": "^3.4.0"
33 | },
34 | "devDependencies": {
35 | "@types/react": "^19.0.0",
36 | "@types/react-copy-to-clipboard": "^5.0.7",
37 | "@types/react-dom": "^19.0.0"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/examples/cra-demo/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/formspree/formspree-js/7c8334b144c9c4a2dc48f6736776b5af5e8580db/examples/cra-demo/public/favicon.ico
--------------------------------------------------------------------------------
/examples/cra-demo/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | Formspree demo
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/examples/cra-demo/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/formspree/formspree-js/7c8334b144c9c4a2dc48f6736776b5af5e8580db/examples/cra-demo/public/logo192.png
--------------------------------------------------------------------------------
/examples/cra-demo/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/formspree/formspree-js/7c8334b144c9c4a2dc48f6736776b5af5e8580db/examples/cra-demo/public/logo512.png
--------------------------------------------------------------------------------
/examples/cra-demo/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/examples/cra-demo/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/examples/cra-demo/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { FormspreeProvider } from '@formspree/react';
2 | import { useState } from 'react';
3 |
4 | import { React19 } from './React19';
5 | import { WithReactHookForm } from './WithReactHookForm';
6 | import PaymentForm from './PaymentForm';
7 | import RecaptchaForm from './RecaptchaForm';
8 | import SimpleForm from './SimpleForm';
9 |
10 | enum Tab {
11 | React19 = 'react-19',
12 | ReactHookForm = 'react-hook-form',
13 | Recaptcha = 'recaptcha',
14 | Simple = 'simple',
15 | Stripe = 'stripe',
16 | }
17 |
18 | const App = () => {
19 | const [tab, setTab] = useState(Tab.Simple);
20 |
21 | return (
22 | <>
23 |
24 |
25 |
32 |
39 |
47 |
54 |
61 |
62 | {tab === Tab.Stripe ? (
63 |
66 |
67 |
68 | ) : tab === Tab.Recaptcha ? (
69 |
70 | ) : tab === Tab.ReactHookForm ? (
71 |
72 | ) : tab === Tab.React19 ? (
73 |
74 | ) : (
75 |
76 | )}
77 |
78 |
86 | >
87 | );
88 | };
89 |
90 | export default App;
91 |
--------------------------------------------------------------------------------
/examples/cra-demo/src/CardExample.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import CopyToClipboard from 'react-copy-to-clipboard';
3 |
4 | type CardExampleProps = {
5 | title: string;
6 | cardNumber: string;
7 | };
8 |
9 | const CardExample = ({ title, cardNumber }: CardExampleProps) => {
10 | const [isCopied, setCopied] = useState(false);
11 |
12 | return (
13 |
14 | {title}
15 | {
18 | setCopied(true);
19 | setTimeout(() => {
20 | setCopied(false);
21 | }, 1000);
22 | }}
23 | >
24 |
36 |
37 | {isCopied && Copied!
}
38 |
39 | );
40 | };
41 |
42 | export default CardExample;
43 |
--------------------------------------------------------------------------------
/examples/cra-demo/src/PaymentForm.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useForm, CardElement, ValidationError } from '@formspree/react';
3 | import CardExample from './CardExample';
4 |
5 | const useOptions = () => {
6 | const options = useMemo(
7 | () => ({
8 | style: {
9 | base: {
10 | color: '#424770',
11 | letterSpacing: '0.025em',
12 | fontFamily: 'Source Code Pro, monospace',
13 | '::placeholder': {
14 | color: '#aab7c4',
15 | },
16 | },
17 | invalid: {
18 | color: '#9e2146',
19 | },
20 | },
21 | }),
22 | []
23 | );
24 |
25 | return options;
26 | };
27 |
28 | const PaymentForm = () => {
29 | const options = useOptions();
30 | const [state, handleSubmit] = useForm(
31 | process.env.REACT_APP_PAYMENT_FORM_ID as string
32 | );
33 |
34 | return state && state.succeeded ? (
35 | Payment has been handled successfully!
36 | ) : (
37 |
90 | );
91 | };
92 |
93 | export default PaymentForm;
94 |
--------------------------------------------------------------------------------
/examples/cra-demo/src/React19.tsx:
--------------------------------------------------------------------------------
1 | import { isSubmissionError, type SubmissionResult } from '@formspree/core';
2 | import { useSubmit, ValidationError } from '@formspree/react';
3 | import { useActionState } from 'react';
4 |
5 | export function React19() {
6 | const submit = useSubmit(process.env.REACT_APP_SIMPLE_FORM_ID as string);
7 |
8 | const [state, action, isPending] = useActionState<
9 | SubmissionResult | null,
10 | FormData
11 | >((_, inputs) => submit(inputs), null);
12 |
13 | if (state && !isSubmissionError(state)) {
14 | return Your message has been sent successfully!
;
15 | }
16 |
17 | return (
18 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/examples/cra-demo/src/RecaptchaForm.tsx:
--------------------------------------------------------------------------------
1 | import { useForm, ValidationError } from '@formspree/react';
2 | import { useState } from 'react';
3 |
4 | import {
5 | GoogleReCaptchaProvider,
6 | useGoogleReCaptcha,
7 | } from 'react-google-recaptcha-v3';
8 |
9 | const ReCaptchaForm = () => {
10 | const { executeRecaptcha } = useGoogleReCaptcha();
11 | const [failReCaptcha, setFailReCaptcha] = useState(false);
12 | const [state, handleSubmit] = useForm(
13 | process.env.REACT_APP_RECAPTCHA_FORM_ID as string,
14 | {
15 | data: {
16 | 'g-recaptcha-response': failReCaptcha
17 | ? () => new Promise((resolve) => resolve('Nonsense!'))
18 | : executeRecaptcha,
19 | },
20 | }
21 | );
22 |
23 | return (
24 |
64 | );
65 | };
66 |
67 | export default () => (
68 |
71 |
72 |
73 | );
74 |
--------------------------------------------------------------------------------
/examples/cra-demo/src/SimpleForm.tsx:
--------------------------------------------------------------------------------
1 | import { useForm, ValidationError } from '@formspree/react';
2 |
3 | const SimpleForm = () => {
4 | const [state, handleSubmit] = useForm(
5 | process.env.REACT_APP_SIMPLE_FORM_ID as string
6 | );
7 |
8 | return (
9 |
10 | {state && state.succeeded ? (
11 |
Your message has been sent successfully!
12 | ) : (
13 |
39 | )}
40 |
41 | );
42 | };
43 |
44 | export default SimpleForm;
45 |
--------------------------------------------------------------------------------
/examples/cra-demo/src/WithReactHookForm.tsx:
--------------------------------------------------------------------------------
1 | import { isSubmissionError } from '@formspree/core';
2 | import { useSubmit } from '@formspree/react';
3 | import { useForm } from 'react-hook-form';
4 |
5 | type Inputs = {
6 | email: string;
7 | message: string;
8 | name: string;
9 | };
10 |
11 | export function WithReactHookForm() {
12 | const {
13 | formState: { errors, isSubmitSuccessful, isSubmitting },
14 | handleSubmit,
15 | register,
16 | setError,
17 | } = useForm();
18 |
19 | const submit = useSubmit(
20 | process.env.REACT_APP_REACT_HOOK_FORM_ID as string
21 | );
22 |
23 | async function onSubmit(inputs: Inputs): Promise {
24 | const result = await submit(inputs);
25 |
26 | if (isSubmissionError(result)) {
27 | const formErrs = result.getFormErrors();
28 | for (const { code, message } of formErrs) {
29 | setError(`root.${code}`, {
30 | type: code,
31 | message,
32 | });
33 | }
34 |
35 | const fieldErrs = result.getAllFieldErrors();
36 | for (const [field, errs] of fieldErrs) {
37 | setError(field, {
38 | message: errs.map((e) => e.message).join(', '),
39 | });
40 | }
41 | }
42 | }
43 |
44 | return (
45 |
46 | {isSubmitSuccessful ? (
47 |
Your message has been sent successfully!
48 | ) : (
49 |
81 | )}
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/examples/cra-demo/src/index.css:
--------------------------------------------------------------------------------
1 | html {
2 | height: 100%;
3 | }
4 |
5 | body {
6 | height: 100%;
7 | box-sizing: content-box;
8 | width: 100%;
9 | font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
10 | }
11 |
12 | #root {
13 | height: 100%;
14 | }
15 |
16 | .container {
17 | max-width: 960px;
18 | margin: 0 auto;
19 | padding: 4rem 2rem;
20 | }
21 |
22 | .tabs {
23 | display: flex;
24 | flex-wrap: wrap;
25 | justify-content: flex-start;
26 | align-items: center;
27 | margin-bottom: 4rem;
28 | }
29 |
30 | .tabs .tab {
31 | background-color: #fafafa;
32 | color: #6772e5;
33 | transition: 0.5s all ease;
34 | margin-right: 1rem;
35 | }
36 |
37 | .tabs .tab:hover {
38 | background-color: #6772e5;
39 | color: #fff;
40 | transition: 0.5s all ease;
41 | }
42 |
43 | .tabs .tab.active {
44 | background-color: #6772e5;
45 | color: #fff;
46 | }
47 |
48 | .tabs .tab:last-of-type {
49 | margin-right: 0;
50 | }
51 |
52 | label {
53 | color: #6b7c93;
54 | font-weight: 300;
55 | letter-spacing: 0.025em;
56 | display: block;
57 | }
58 |
59 | label.forCheckbox {
60 | display: inline-block;
61 | margin-bottom: 1em;
62 | margin-right: 0.25em;
63 | cursor: pointer;
64 | }
65 |
66 | button {
67 | white-space: nowrap;
68 | border: 0;
69 | outline: 0;
70 | display: inline-block;
71 | height: 40px;
72 | line-height: 40px;
73 | padding: 0 14px;
74 | box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11), 0 1px 3px rgba(0, 0, 0, 0.08);
75 | color: #fff;
76 | border-radius: 4px;
77 | font-size: 15px;
78 | font-weight: 600;
79 | text-transform: uppercase;
80 | letter-spacing: 0.025em;
81 | background-color: #6772e5;
82 | text-decoration: none;
83 | -webkit-transition: all 150ms ease;
84 | transition: all 150ms ease;
85 | margin-top: 10px;
86 | user-select: none;
87 | }
88 |
89 | button:disabled {
90 | box-shadow: none;
91 | opacity: 0.5;
92 | pointer-events: none;
93 | }
94 |
95 | button:hover {
96 | color: #fff;
97 | cursor: pointer;
98 | background-color: #7795f8;
99 | transform: translateY(-1px);
100 | box-shadow: 0 7px 14px rgba(50, 50, 93, 0.1), 0 3px 6px rgba(0, 0, 0, 0.08);
101 | }
102 |
103 | textarea,
104 | input:not([type='checkbox']),
105 | .StripeElement {
106 | display: block;
107 | margin: 10px 0 20px 0;
108 | max-width: 500px;
109 | width: 100%;
110 | padding: 10px 14px;
111 | font-size: 1em;
112 | font-family: 'Source Code Pro', monospace;
113 | box-shadow: rgba(50, 50, 93, 0.14902) 0px 1px 3px,
114 | rgba(0, 0, 0, 0.0196078) 0px 1px 0px;
115 | border: 0;
116 | outline: 0;
117 | border-radius: 4px;
118 | background: white;
119 | }
120 |
121 | input[type='checkbox'] {
122 | cursor: pointer;
123 | }
124 |
125 | input::placeholder {
126 | color: #aab7c4;
127 | }
128 |
129 | input:focus,
130 | .StripeElement--focus {
131 | box-shadow: rgba(50, 50, 93, 0.109804) 0px 4px 6px,
132 | rgba(0, 0, 0, 0.0784314) 0px 1px 3px;
133 | -webkit-transition: all 150ms ease;
134 | transition: all 150ms ease;
135 | }
136 |
137 | .StripeElement.IdealBankElement,
138 | .StripeElement.FpxBankElement,
139 | .StripeElement.PaymentRequestButton {
140 | padding: 0;
141 | }
142 |
143 | .StripeElement.PaymentRequestButton {
144 | height: 40px;
145 | }
146 |
147 | .block {
148 | max-width: 500px;
149 | }
150 |
151 | @media screen and (max-width: 600px) {
152 | .block {
153 | max-width: 350px;
154 | }
155 | }
156 |
157 | .error {
158 | color: #e12626;
159 | display: block;
160 | margin-top: 1em;
161 | margin-bottom: 1em;
162 | }
163 |
164 | .info {
165 | margin-top: 2rem;
166 | color: #6b7c93;
167 | }
168 |
169 | .info li {
170 | position: relative;
171 | margin-bottom: 0.5rem;
172 | }
173 |
174 | .info svg {
175 | margin-left: 0.4rem;
176 | fill: #6b7c93;
177 | cursor: pointer;
178 | }
179 |
180 | .info svg:hover {
181 | fill: #6772e5;
182 | }
183 |
184 | .info span {
185 | display: block;
186 | font-style: italic;
187 | margin-top: 1rem;
188 | }
189 |
190 | .alert {
191 | position: absolute;
192 | right: 0;
193 | top: 0;
194 | background: #5c68e3;
195 | color: #fff;
196 | padding: 0.3rem 0.5rem;
197 | border-radius: 5px;
198 | }
199 |
--------------------------------------------------------------------------------
/examples/cra-demo/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import App from './App';
5 |
6 | const root = ReactDOM.createRoot(
7 | document.getElementById('root') as HTMLElement
8 | );
9 | root.render(
10 |
11 |
12 |
13 | );
14 |
--------------------------------------------------------------------------------
/examples/cra-demo/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/examples/cra-demo/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "esModuleInterop": true,
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "forceConsistentCasingInFileNames": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "module": "esnext",
13 | "moduleResolution": "node",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx"
18 | },
19 | "include": ["src"],
20 | "exclude": ["node_modules"]
21 | }
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "workspaces": [
4 | "packages/*",
5 | "examples/*"
6 | ],
7 | "scripts": {
8 | "build": "turbo run build",
9 | "changeset": "changeset",
10 | "clean": "turbo run clean && rm -rf node_modules",
11 | "dev": "turbo run dev",
12 | "format": "prettier --write \"**/*.{ts,tsx,md}\"",
13 | "lint": "turbo run lint",
14 | "prepare": "husky install",
15 | "prerelease": "turbo run build --filter=\"./packages/*\"",
16 | "release": "changeset publish",
17 | "test": "turbo run test",
18 | "typecheck": "turbo run typecheck",
19 | "version-packages": "changeset version"
20 | },
21 | "devDependencies": {
22 | "@babel/core": "^7.22.5",
23 | "@babel/preset-env": "^7.22.5",
24 | "@babel/preset-typescript": "^7.22.5",
25 | "@changesets/cli": "^2.22.0",
26 | "@types/jest": "^29.5.2",
27 | "@typescript-eslint/eslint-plugin": "^5.60.0",
28 | "@typescript-eslint/parser": "^5.60.0",
29 | "babel-jest": "^29.5.0",
30 | "eslint": "^8.15.0",
31 | "eslint-config-prettier": "^8.8.0",
32 | "eslint-plugin-jest": "^27.2.2",
33 | "husky": "^8.0.0",
34 | "isomorphic-fetch": "^3.0.0",
35 | "jest": "^29.5.0",
36 | "jest-environment-jsdom": "^29.5.0",
37 | "lint-staged": "^13.2.2",
38 | "prettier": "^2.8.8",
39 | "sort-package-json": "^2.4.1",
40 | "tsup": "^6.2.2",
41 | "turbo": "latest",
42 | "typescript": "^5.1.3"
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/packages/formspree-core/.babelrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "test": {
4 | "presets": ["@babel/preset-env", "@babel/preset-typescript"]
5 | }
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/packages/formspree-core/.eslintrc.yml:
--------------------------------------------------------------------------------
1 | root: true
2 | extends:
3 | - '../../.eslintrc.yml'
4 | ignorePatterns:
5 | - jest.config.js
6 | - jest.setup.js
7 | - dist/
8 | parserOptions:
9 | project: './tsconfig.json'
10 |
--------------------------------------------------------------------------------
/packages/formspree-core/.lintstagedrc.yml:
--------------------------------------------------------------------------------
1 | package.json: yarn sort-package-json
2 | '*.{json,md,yml}': yarn prettier --write
3 | '*.{js,ts}':
4 | - yarn prettier --write
5 | # calling directly to eslint binary this instead of doing `yarn lint`
6 | # because lint-staged will pass the list of files
7 | - yarn eslint
8 |
--------------------------------------------------------------------------------
/packages/formspree-core/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 4.0.0
4 |
5 | ### Major Changes
6 |
7 | - a00b642: Support React 19 and stripe-js 5
8 |
9 | ## 3.0.4
10 |
11 | ### Patch Changes
12 |
13 | - ceaae3d: Bump (patch) @formspree/core and @formspree/react to verify that the release pipeline is fixed
14 |
15 | ## 3.0.3
16 |
17 | ### Patch Changes
18 |
19 | - 805aa4d: Bump (patch) @formspree/core and @formspree/react to fix the release pipeline
20 |
21 | ## 3.0.2
22 |
23 | ### Patch Changes
24 |
25 | - 6c31cd1: (type) Support array as FieldValue
26 |
27 | ## 3.0.1
28 |
29 | ### Patch Changes
30 |
31 | - 56a444b: remove unused package-specific yarn.lock
32 |
33 | ## 3.0.0
34 |
35 | ### Major Changes
36 |
37 | - 49730d9: ## Improve error handling
38 |
39 | - `@formspree/core` `submitForm` function now will never rejects but always produces a type of `SubmissionResult`, different types of the result can be refined/narrowed down using the field `kind`.
40 | - Provide `SubmissionErrorResult` which can be used to get an array of form errors and/or field errors (by field name)
41 | - `Response` is no longer made available on the submission result
42 | - Update `@formspree/react` for the changes introduced to `@formspree/core`
43 |
44 | - d025831: `@formspree/core`
45 |
46 | - rename client config `stripePromise` to `stripe` since it expects the resolved Stripe client not a promise
47 |
48 | `@formspree/react`
49 |
50 | - add a new hook: `useSubmit` which is suitable with code that uses other ways to manage submission state (e.g. with a library like react-hook-form)
51 | - update `useForm` to use `useSubmit` under the hood
52 | - fix: `FormspreeContext` updates the client when `props.project` change
53 |
54 | ### Minor Changes
55 |
56 | - 4c40e1b: # Fix types in @formspree/core
57 |
58 | ## `@formspree/core`
59 |
60 | - fix `SubmissionData` has a type of `any` causing everything after it to opt-out typechecking
61 | - remove a no-op `teardown` method on `Client` and `Session`
62 | - remove `utils.now` and use `Date.now` instead
63 | - remove unused functions from `utils` module: `append`, `toCamel`, `camelizeTopKeys`
64 | - add tests for `utils.appendExtraData` and convert the test file to typescript
65 | - add tests for `session.data()`
66 | - no longer export `Session` type
67 |
68 | ## `@formspree/react`
69 |
70 | - update types as a result of `SubmissionData` is no longer `any`
71 | - fix `createPaymentMethod` does not properly map payload when the submission data is a type of `FormData`
72 | - fix the `Client` is not updated when project changes
73 |
74 | ## 2.8.3
75 |
76 | ### Patch Changes
77 |
78 | - a359edd: Upgrade jest to v29 using centralized dependency, and run tests in CI
79 |
80 | ## 2.8.2
81 |
82 | ### Patch Changes
83 |
84 | - Unify typescript version and enforce typechecking
85 |
86 | - Centralize typescript and its related `devDependencies` to project root for version consistency
87 | - Centralize `tsconfig` to root and have package-specific `tsconfig` extends it for consistency
88 | - Make typescript config more strict especially around detecting `null` and `undefined`
89 | - Run typecheck on test folders
90 | - Fix type errors
91 | - Add Turbo `typecheck` task which runs `tsc` in typechecking mode
92 | - Set up Github Action to run `typecheck` task
93 |
94 | ## 2.8.1
95 |
96 | ### Patch Changes
97 |
98 | - Catch Formspree errors using legacy format
99 | - Better error handling
100 |
101 | ## 2.8.0
102 |
103 | ### Minor Changes
104 |
105 | - Conversion to monorepo. Path for type imports changed. Lazy loading stripe.
106 |
107 | ## 2.5.1
108 |
109 | - Update dependencies for security.
110 |
111 | ## 2.5.0
112 |
113 | - Low-level updates to session management.
114 |
115 | ## 2.4.0
116 |
117 | - Bundle an IIFE version of the library, for in-browser use
118 | - Auto-start browser session if `window` is present on init
119 |
120 | ## 2.3.0
121 |
122 | - Move `fetch` and `Promise` polyfills to external `dependencies` (rather than bundled), which means `fetch` calls will be isomorphic (work in Node and browser environments)
123 |
124 | ## 2.2.0
125 |
126 | - Update function failure types
127 |
128 | ## 2.1.0
129 |
130 | - Add a `startBrowserSession` function to the client
131 |
132 | Now, you can safely run `createClient` on the server-side, because instantiation
133 | no longer relies on `window` being there 🎉
134 |
135 | ## 2.0.0
136 |
137 | - Migrate to TypeScript
138 | - Accept `site` option in factory
139 | - Add `invokeFunction` method to client
140 | - **Breaking change**: Export a `createClient` factory function (instead of as a default value)
141 | - **Breaking change**: New argument structure for `submitForm` (using the form `key` is now required)
142 |
143 | ## 1.5.0
144 |
145 | - Accept a `clientName` property to set in the `StaticKit-Client` header.
146 | - Fix bug serializing JSON body payload.
147 |
148 | ## 1.4.0
149 |
150 | - Accept `site` + `form` combo (in lieu of `id`) for identifying forms.
151 |
152 | ## 1.3.0
153 |
154 | - Fix bugs affecting form data that is not an instance of `FormData`.
155 | - Remove use of `async`/`await` in favor of promises, to reduce transpilating needs.
156 | - Remove use of `class` to reduce transpiling needs.
157 | - Bundle in [ponyfills](https://github.com/sindresorhus/ponyfill) for `window.fetch`, `Promise`, and `objectAssign`.
158 |
--------------------------------------------------------------------------------
/packages/formspree-core/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019-2020 Unstack, LLC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/formspree-core/README.md:
--------------------------------------------------------------------------------
1 | # Formspree Core
2 |
3 | The core client library for [Formspree](https://formspree.io).
4 |
5 | ## Releasing
6 |
7 | Run the following to publish a new version:
8 |
9 | ```
10 | npm run release
11 | ```
12 |
--------------------------------------------------------------------------------
/packages/formspree-core/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('jest').Config} */
2 | const config = {
3 | setupFilesAfterEnv: ['/jest.setup.js'],
4 | testEnvironment: 'jsdom',
5 | };
6 |
7 | module.exports = config;
8 |
--------------------------------------------------------------------------------
/packages/formspree-core/jest.setup.js:
--------------------------------------------------------------------------------
1 | // Fix: ReferenceError: Response is not defined
2 | import 'isomorphic-fetch';
3 |
--------------------------------------------------------------------------------
/packages/formspree-core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@formspree/core",
3 | "version": "4.0.0",
4 | "private": false,
5 | "description": "The core library for Formspree",
6 | "homepage": "https://formspree.io",
7 | "bugs": {
8 | "url": "https://github.com/formspree/formspree-core/issues"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "https://github.com/formspree/formspree-core.git"
13 | },
14 | "license": "MIT",
15 | "author": "Derrick Reimer",
16 | "contributors": [
17 | "Derrick Reimer",
18 | "Cole Krumbholz",
19 | "Ismail Ghallou"
20 | ],
21 | "sideEffects": false,
22 | "main": "./dist/index.js",
23 | "types": "./dist/index.d.ts",
24 | "files": [
25 | "dist/**"
26 | ],
27 | "scripts": {
28 | "build": "tsup src/index.ts --format esm,cjs --dts --minify",
29 | "clean": "rm -rf dist && rm -rf node_modules",
30 | "dev": "tsup src/index.ts --format esm,cjs --dts --sourcemap --watch",
31 | "lint": "eslint ./src ./test",
32 | "test": "jest",
33 | "typecheck": "tsc --noEmit"
34 | },
35 | "dependencies": {
36 | "@stripe/stripe-js": "^5.7.0"
37 | },
38 | "publishConfig": {
39 | "access": "public",
40 | "registry": "https://registry.npmjs.org"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/packages/formspree-core/src/base64.d.ts:
--------------------------------------------------------------------------------
1 | export function btoa(value: string): string;
2 | export function atob(value: string): string;
3 |
--------------------------------------------------------------------------------
/packages/formspree-core/src/base64.js:
--------------------------------------------------------------------------------
1 | const b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
2 | const b64re =
3 | // suppress existing violation: Unnecessary escape character: \/
4 | // eslint-disable-next-line no-useless-escape
5 | /^(?:[A-Za-z\d+\/]{4})*?(?:[A-Za-z\d+\/]{2}(?:==)?|[A-Za-z\d+\/]{3}=?)?$/;
6 |
7 | export function btoa(string) {
8 | string = String(string);
9 | var bitmap,
10 | a,
11 | b,
12 | c,
13 | result = '',
14 | i = 0,
15 | rest = string.length % 3; // To determine the final padding
16 |
17 | for (; i < string.length; ) {
18 | if (
19 | (a = string.charCodeAt(i++)) > 255 ||
20 | (b = string.charCodeAt(i++)) > 255 ||
21 | (c = string.charCodeAt(i++)) > 255
22 | )
23 | throw new TypeError(
24 | "Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range."
25 | );
26 |
27 | bitmap = (a << 16) | (b << 8) | c;
28 | result +=
29 | b64.charAt((bitmap >> 18) & 63) +
30 | b64.charAt((bitmap >> 12) & 63) +
31 | b64.charAt((bitmap >> 6) & 63) +
32 | b64.charAt(bitmap & 63);
33 | }
34 |
35 | // If there's need of padding, replace the last 'A's with equal signs
36 | return rest ? result.slice(0, rest - 3) + '==='.substring(rest) : result;
37 | }
38 |
39 | export function atob(string) {
40 | // atob can work with strings with whitespaces, even inside the encoded part,
41 | // but only \t, \n, \f, \r and ' ', which can be stripped.
42 | string = String(string).replace(/[\t\n\f\r ]+/g, '');
43 | if (!b64re.test(string))
44 | throw new TypeError(
45 | "Failed to execute 'atob' on 'Window': The string to be decoded is not correctly encoded."
46 | );
47 |
48 | // Adding the padding if missing, for semplicity
49 | string += '=='.slice(2 - (string.length & 3));
50 | var bitmap,
51 | result = '',
52 | r1,
53 | r2,
54 | i = 0;
55 | for (; i < string.length; ) {
56 | bitmap =
57 | (b64.indexOf(string.charAt(i++)) << 18) |
58 | (b64.indexOf(string.charAt(i++)) << 12) |
59 | ((r1 = b64.indexOf(string.charAt(i++))) << 6) |
60 | (r2 = b64.indexOf(string.charAt(i++)));
61 |
62 | result +=
63 | r1 === 64
64 | ? String.fromCharCode((bitmap >> 16) & 255)
65 | : r2 === 64
66 | ? String.fromCharCode((bitmap >> 16) & 255, (bitmap >> 8) & 255)
67 | : String.fromCharCode(
68 | (bitmap >> 16) & 255,
69 | (bitmap >> 8) & 255,
70 | bitmap & 255
71 | );
72 | }
73 | return result;
74 | }
75 |
--------------------------------------------------------------------------------
/packages/formspree-core/src/core.ts:
--------------------------------------------------------------------------------
1 | import type { Stripe } from '@stripe/stripe-js';
2 | import { Session } from './session';
3 | import {
4 | SubmissionError,
5 | SubmissionSuccess,
6 | StripeSCAPending,
7 | isServerErrorResponse,
8 | isServerSuccessResponse,
9 | isServerStripeSCAPendingResponse,
10 | type FieldValues,
11 | type SubmissionData,
12 | type SubmissionOptions,
13 | type SubmissionResult,
14 | } from './submission';
15 | import {
16 | appendExtraData,
17 | clientHeader,
18 | encode64,
19 | isUnknownObject,
20 | } from './utils';
21 |
22 | export interface Config {
23 | project?: string;
24 | stripe?: Stripe;
25 | }
26 |
27 | export class Client {
28 | project: string | undefined;
29 | stripe: Stripe | undefined;
30 | private readonly session?: Session;
31 |
32 | constructor(config: Config = {}) {
33 | this.project = config.project;
34 | this.stripe = config.stripe;
35 |
36 | if (typeof window !== 'undefined') {
37 | this.session = new Session();
38 | }
39 | }
40 |
41 | /**
42 | * Submit a form.
43 | *
44 | * @param formKey - The form key.
45 | * @param data - An object or FormData instance containing submission data.
46 | * @param args - An object of form submission data.
47 | */
48 | async submitForm(
49 | formKey: string,
50 | data: SubmissionData,
51 | opts: SubmissionOptions = {}
52 | ): Promise> {
53 | const endpoint = opts.endpoint || 'https://formspree.io';
54 | const url = this.project
55 | ? `${endpoint}/p/${this.project}/f/${formKey}`
56 | : `${endpoint}/f/${formKey}`;
57 |
58 | const headers: { [key: string]: string } = {
59 | Accept: 'application/json',
60 | 'Formspree-Client': clientHeader(opts.clientName),
61 | };
62 |
63 | if (this.session) {
64 | headers['Formspree-Session-Data'] = encode64(this.session.data());
65 | }
66 |
67 | if (!(data instanceof FormData)) {
68 | headers['Content-Type'] = 'application/json';
69 | }
70 |
71 | async function makeFormspreeRequest(
72 | data: SubmissionData
73 | ): Promise | StripeSCAPending> {
74 | try {
75 | const res = await fetch(url, {
76 | method: 'POST',
77 | mode: 'cors',
78 | body: data instanceof FormData ? data : JSON.stringify(data),
79 | headers,
80 | });
81 |
82 | const body = await res.json();
83 |
84 | if (isUnknownObject(body)) {
85 | if (isServerErrorResponse(body)) {
86 | return Array.isArray(body.errors)
87 | ? new SubmissionError(...body.errors)
88 | : new SubmissionError({ message: body.error });
89 | }
90 |
91 | if (isServerStripeSCAPendingResponse(body)) {
92 | return new StripeSCAPending(
93 | body.stripe.paymentIntentClientSecret,
94 | body.resubmitKey
95 | );
96 | }
97 |
98 | if (isServerSuccessResponse(body)) {
99 | return new SubmissionSuccess({ next: body.next });
100 | }
101 | }
102 |
103 | return new SubmissionError({
104 | message: 'Unexpected response format',
105 | });
106 | } catch (err) {
107 | const message =
108 | err instanceof Error
109 | ? err.message
110 | : `Unknown error while posting to Formspree: ${JSON.stringify(
111 | err
112 | )}`;
113 | return new SubmissionError({ message });
114 | }
115 | }
116 |
117 | if (this.stripe && opts.createPaymentMethod) {
118 | const createPaymentMethodResult = await opts.createPaymentMethod();
119 |
120 | if (createPaymentMethodResult.error) {
121 | return new SubmissionError({
122 | code: 'STRIPE_CLIENT_ERROR',
123 | field: 'paymentMethod',
124 | message: 'Error creating payment method',
125 | });
126 | }
127 |
128 | // Add the paymentMethod to the data
129 | appendExtraData(
130 | data,
131 | 'paymentMethod',
132 | createPaymentMethodResult.paymentMethod.id
133 | );
134 |
135 | // Send a request to Formspree server to handle the payment method
136 | const result = await makeFormspreeRequest(data);
137 |
138 | if (result.kind === 'error') {
139 | return result;
140 | }
141 |
142 | if (result.kind === 'stripePluginPending') {
143 | const stripeResult = await this.stripe.handleCardAction(
144 | result.paymentIntentClientSecret
145 | );
146 |
147 | if (stripeResult.error) {
148 | return new SubmissionError({
149 | code: 'STRIPE_CLIENT_ERROR',
150 | field: 'paymentMethod',
151 | message: 'Stripe SCA error',
152 | });
153 | }
154 |
155 | // `paymentMethod` must not be on the payload when resubmitting
156 | // the form to handle Stripe SCA.
157 | if (data instanceof FormData) {
158 | data.delete('paymentMethod');
159 | } else {
160 | delete data.paymentMethod;
161 | }
162 |
163 | appendExtraData(data, 'paymentIntent', stripeResult.paymentIntent.id);
164 | appendExtraData(data, 'resubmitKey', result.resubmitKey);
165 |
166 | // Resubmit the form with the paymentIntent and resubmitKey
167 | const resubmitResult = await makeFormspreeRequest(data);
168 | assertSubmissionResult(resubmitResult);
169 | return resubmitResult;
170 | }
171 |
172 | return result;
173 | }
174 |
175 | const result = await makeFormspreeRequest(data);
176 | assertSubmissionResult(result);
177 | return result;
178 | }
179 | }
180 |
181 | // assertSubmissionResult ensures the result is SubmissionResult
182 | function assertSubmissionResult(
183 | result: SubmissionResult | StripeSCAPending
184 | ): asserts result is SubmissionResult {
185 | const { kind } = result;
186 | if (kind !== 'success' && kind !== 'error') {
187 | throw new Error(`Unexpected submission result (kind: ${kind})`);
188 | }
189 | }
190 |
191 | /**
192 | * Constructs the client object.
193 | */
194 | export const createClient = (config?: Config): Client => new Client(config);
195 |
196 | /**
197 | * Fetches the global default client.
198 | */
199 | export const getDefaultClient = (): Client => {
200 | if (!defaultClientSingleton) {
201 | defaultClientSingleton = createClient();
202 | }
203 | return defaultClientSingleton;
204 | };
205 |
206 | /**
207 | * The global default client. Note, this client may not get torn down.
208 | */
209 | let defaultClientSingleton: Client;
210 |
--------------------------------------------------------------------------------
/packages/formspree-core/src/env.d.ts:
--------------------------------------------------------------------------------
1 | // declare PhantomJS properties that we check in session webdriver.
2 | declare interface Window {
3 | _phantom?: unknown;
4 | callPhantom?: unknown;
5 | }
6 |
--------------------------------------------------------------------------------
/packages/formspree-core/src/index.ts:
--------------------------------------------------------------------------------
1 | export {
2 | createClient,
3 | getDefaultClient,
4 | type Client,
5 | type Config,
6 | } from './core';
7 |
8 | export {
9 | SubmissionError,
10 | isSubmissionError,
11 | type FieldErrorCode,
12 | type FieldValues,
13 | type FormErrorCode,
14 | type SubmissionData,
15 | type SubmissionOptions,
16 | type SubmissionResult,
17 | type SubmissionSuccess,
18 | } from './submission';
19 |
20 | export { appendExtraData } from './utils';
21 |
--------------------------------------------------------------------------------
/packages/formspree-core/src/session.ts:
--------------------------------------------------------------------------------
1 | import { atob } from './base64';
2 |
3 | /**
4 | * Check whether the user agent is controlled by an automation.
5 | */
6 | const webdriver = (): boolean => {
7 | return (
8 | navigator.webdriver ||
9 | !!document.documentElement.getAttribute(atob('d2ViZHJpdmVy')) ||
10 | !!window.callPhantom ||
11 | !!window._phantom
12 | );
13 | };
14 |
15 | export class Session {
16 | private readonly loadedAt: number;
17 | private readonly webdriver: boolean;
18 |
19 | constructor() {
20 | this.loadedAt = Date.now();
21 | this.webdriver = webdriver();
22 | }
23 |
24 | data(): {
25 | loadedAt: number;
26 | webdriver: boolean;
27 | } {
28 | return {
29 | loadedAt: this.loadedAt,
30 | webdriver: this.webdriver,
31 | };
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/packages/formspree-core/src/submission.ts:
--------------------------------------------------------------------------------
1 | import type { PaymentMethodResult } from '@stripe/stripe-js';
2 | import type { UnknownObject } from './utils';
3 |
4 | export type SubmissionData = FormData | T;
5 |
6 | export type FieldValues = Record;
7 | type FieldValue = string | number | boolean | null | undefined;
8 |
9 | export type SubmissionOptions = {
10 | clientName?: string;
11 | createPaymentMethod?: () => Promise;
12 | endpoint?: string;
13 | };
14 |
15 | export type SubmissionResult =
16 | | SubmissionSuccess
17 | | SubmissionError;
18 |
19 | export class SubmissionSuccess {
20 | readonly kind = 'success';
21 | readonly next: string;
22 |
23 | constructor(serverResponse: ServerSuccessResponse) {
24 | this.next = serverResponse.next;
25 | }
26 | }
27 |
28 | type ServerSuccessResponse = { next: string };
29 |
30 | export function isServerSuccessResponse(
31 | obj: UnknownObject
32 | ): obj is ServerSuccessResponse {
33 | return 'next' in obj && typeof obj.next === 'string';
34 | }
35 |
36 | export class StripeSCAPending {
37 | readonly kind = 'stripePluginPending';
38 |
39 | constructor(
40 | readonly paymentIntentClientSecret: string,
41 | readonly resubmitKey: string
42 | ) {}
43 | }
44 |
45 | export type ServerStripeSCAPendingResponse = {
46 | resubmitKey: string;
47 | stripe: { paymentIntentClientSecret: string };
48 | };
49 |
50 | export function isServerStripeSCAPendingResponse(
51 | obj: UnknownObject
52 | ): obj is ServerStripeSCAPendingResponse {
53 | if (
54 | 'stripe' in obj &&
55 | 'resubmitKey' in obj &&
56 | typeof obj.resubmitKey === 'string'
57 | ) {
58 | const { stripe } = obj;
59 | return (
60 | typeof stripe === 'object' &&
61 | stripe != null &&
62 | 'paymentIntentClientSecret' in stripe &&
63 | typeof stripe.paymentIntentClientSecret === 'string'
64 | );
65 | }
66 | return false;
67 | }
68 |
69 | export function isSubmissionError(
70 | result: SubmissionResult
71 | ): result is SubmissionError {
72 | return result.kind === 'error';
73 | }
74 |
75 | export class SubmissionError {
76 | readonly kind = 'error';
77 |
78 | private readonly formErrors: FormError[] = [];
79 | private readonly fieldErrors: Map = new Map();
80 |
81 | constructor(...serverErrors: ServerError[]) {
82 | for (const err of serverErrors) {
83 | // form errors
84 | if (!err.field) {
85 | this.formErrors.push({
86 | code:
87 | err.code && isFormErrorCode(err.code) ? err.code : 'UNSPECIFIED',
88 | message: err.message,
89 | });
90 | continue;
91 | }
92 |
93 | const fieldErrors = this.fieldErrors.get(err.field) ?? [];
94 | fieldErrors.push({
95 | code: err.code && isFieldErrorCode(err.code) ? err.code : 'UNSPECIFIED',
96 | message: err.message,
97 | });
98 | this.fieldErrors.set(err.field, fieldErrors);
99 | }
100 | }
101 |
102 | getFormErrors(): readonly FormError[] {
103 | return [...this.formErrors];
104 | }
105 |
106 | getFieldErrors(field: K): readonly FieldError[] {
107 | return this.fieldErrors.get(field) ?? [];
108 | }
109 |
110 | getAllFieldErrors(): readonly [keyof T, readonly FieldError[]][] {
111 | return Array.from(this.fieldErrors);
112 | }
113 | }
114 |
115 | export type FormError = {
116 | readonly code: FormErrorCode | 'UNSPECIFIED';
117 | readonly message: string;
118 | };
119 |
120 | function isFormErrorCode(code: string): code is FormErrorCode {
121 | return code in FormErrorCodeEnum;
122 | }
123 |
124 | export type FormErrorCode = ValueOf;
125 |
126 | export const FormErrorCodeEnum = {
127 | BLOCKED: 'BLOCKED',
128 | EMPTY: 'EMPTY',
129 | FILES_TOO_BIG: 'FILES_TOO_BIG',
130 | FORM_NOT_FOUND: 'FORM_NOT_FOUND',
131 | INACTIVE: 'INACTIVE',
132 | NO_FILE_UPLOADS: 'NO_FILE_UPLOADS',
133 | PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND',
134 | TOO_MANY_FILES: 'TOO_MANY_FILES',
135 | } as const;
136 |
137 | export type FieldError = {
138 | readonly code: FieldErrorCode | 'UNSPECIFIED';
139 | readonly message: string;
140 | };
141 |
142 | function isFieldErrorCode(code: string): code is FieldErrorCode {
143 | return code in FieldErrorCodeEnum;
144 | }
145 |
146 | export type FieldErrorCode = ValueOf;
147 |
148 | export const FieldErrorCodeEnum = {
149 | REQUIRED_FIELD_EMPTY: 'REQUIRED_FIELD_EMPTY',
150 | REQUIRED_FIELD_MISSING: 'REQUIRED_FIELD_MISSING',
151 | STRIPE_CLIENT_ERROR: 'STRIPE_CLIENT_ERROR',
152 | STRIPE_SCA_ERROR: 'STRIPE_SCA_ERROR',
153 | TYPE_EMAIL: 'TYPE_EMAIL',
154 | TYPE_NUMERIC: 'TYPE_NUMERIC',
155 | TYPE_TEXT: 'TYPE_TEXT',
156 | } as const;
157 |
158 | export function isServerErrorResponse(
159 | obj: UnknownObject
160 | ): obj is ServerErrorResponse {
161 | return (
162 | ('errors' in obj &&
163 | Array.isArray(obj.errors) &&
164 | obj.errors.every((err) => typeof err.message === 'string')) ||
165 | ('error' in obj && typeof obj.error === 'string')
166 | );
167 | }
168 |
169 | export type ServerErrorResponse = {
170 | error: string;
171 | errors?: ServerError[];
172 | };
173 |
174 | type ServerError = {
175 | code?: string;
176 | details?: Record;
177 | field?: string;
178 | message: string;
179 | };
180 |
181 | type ValueOf = T[keyof T];
182 |
--------------------------------------------------------------------------------
/packages/formspree-core/src/utils.ts:
--------------------------------------------------------------------------------
1 | import { version } from '../package.json';
2 | import { btoa } from './base64';
3 | import type { SubmissionData } from './submission';
4 |
5 | /**
6 | * Base-64 encodes a (JSON-castable) object.
7 | *
8 | * @param obj - The object to encode.
9 | */
10 | export const encode64 = (obj: object): string => {
11 | return btoa(JSON.stringify(obj));
12 | };
13 |
14 | /**
15 | * Generates a client header.
16 | *
17 | * @param givenLabel
18 | */
19 | export const clientHeader = (givenLabel: string | undefined): string => {
20 | const label = `@formspree/core@${version}`;
21 | if (!givenLabel) return label;
22 | return `${givenLabel} ${label}`;
23 | };
24 |
25 | export function appendExtraData(
26 | formData: SubmissionData,
27 | prop: string,
28 | value: string
29 | ): void {
30 | if (formData instanceof FormData) {
31 | formData.append(prop, value);
32 | } else {
33 | formData[prop] = value;
34 | }
35 | }
36 |
37 | export type UnknownObject = Record;
38 |
39 | export function isUnknownObject(value: unknown): value is UnknownObject {
40 | return value !== null && typeof value === 'object';
41 | }
42 |
--------------------------------------------------------------------------------
/packages/formspree-core/test/core.test.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | PaymentIntentResult,
3 | PaymentMethodResult,
4 | Stripe,
5 | } from '@stripe/stripe-js';
6 | import { version } from '../package.json';
7 | import { createClient, type Client } from '../src/core';
8 | import {
9 | FieldErrorCodeEnum,
10 | FormErrorCodeEnum,
11 | SubmissionError,
12 | SubmissionSuccess,
13 | type FieldError,
14 | type FormError,
15 | type ServerErrorResponse,
16 | type ServerStripeSCAPendingResponse,
17 | } from '../src/submission';
18 |
19 | describe('Client.submitForm', () => {
20 | const fetch = jest.spyOn(global, 'fetch');
21 |
22 | beforeEach(() => {
23 | fetch.mockReset();
24 | });
25 |
26 | const now = new Date('2023-07-07T04:41:09.936Z').getTime();
27 | const expectedSessionData =
28 | 'eyJsb2FkZWRBdCI6MTY4ODcwNDg2OTkzNiwid2ViZHJpdmVyIjpmYWxzZX0=';
29 |
30 | beforeEach(() => {
31 | jest.useFakeTimers({ now });
32 | });
33 |
34 | afterEach(() => {
35 | jest.useRealTimers();
36 | });
37 |
38 | describe('when submitting with a FormData', () => {
39 | it('makes the request to the submission url', async () => {
40 | const testCases = [
41 | {
42 | client: createClient(),
43 | expectedUrl: 'https://formspree.io/f/test-form-id',
44 | },
45 | {
46 | client: createClient({ project: 'test-project-id' }),
47 | expectedUrl: 'https://formspree.io/p/test-project-id/f/test-form-id',
48 | },
49 | ];
50 |
51 | for (const { client, expectedUrl } of testCases) {
52 | fetch.mockClear();
53 |
54 | const data = new FormData();
55 | data.set('email', 'test@example.com');
56 | data.set('message', 'Hello!');
57 | // support files
58 | data.set(
59 | 'attachment',
60 | new Blob(['fake-image-content'], { type: 'image/jpeg' })
61 | );
62 | await client.submitForm('test-form-id', data);
63 |
64 | expect(fetch).toHaveBeenCalledTimes(1);
65 | expect(fetch).toHaveBeenLastCalledWith(expectedUrl, {
66 | body: data,
67 | headers: {
68 | Accept: 'application/json',
69 | 'Formspree-Client': `@formspree/core@${version}`,
70 | 'Formspree-Session-Data': expectedSessionData,
71 | },
72 | method: 'POST',
73 | mode: 'cors',
74 | });
75 | }
76 | });
77 | });
78 |
79 | describe('when submitting with a plain object', () => {
80 | it('makes the request to the submission url', async () => {
81 | const testCases = [
82 | {
83 | client: createClient(),
84 | expectedUrl: 'https://formspree.io/f/test-form-id',
85 | },
86 | {
87 | client: createClient({ project: 'test-project-id' }),
88 | expectedUrl: 'https://formspree.io/p/test-project-id/f/test-form-id',
89 | },
90 | ];
91 |
92 | for (const { client, expectedUrl } of testCases) {
93 | fetch.mockClear();
94 |
95 | const data = { email: 'test@example.com', message: 'Hello!' };
96 | await client.submitForm('test-form-id', data);
97 |
98 | expect(fetch).toHaveBeenCalledTimes(1);
99 | expect(fetch).toHaveBeenLastCalledWith(expectedUrl, {
100 | body: JSON.stringify(data),
101 | headers: {
102 | Accept: 'application/json',
103 | 'Content-Type': 'application/json',
104 | 'Formspree-Client': `@formspree/core@${version}`,
105 | 'Formspree-Session-Data': expectedSessionData,
106 | },
107 | method: 'POST',
108 | mode: 'cors',
109 | });
110 | }
111 | });
112 | });
113 |
114 | describe('when the server returns an error response', () => {
115 | type TestCase = {
116 | name: string;
117 | response: {
118 | body: ServerErrorResponse;
119 | status: number;
120 | };
121 | expected: {
122 | formErrors: FormError[];
123 | fieldErrors: [string, FieldError[]][];
124 | };
125 | };
126 |
127 | const testCases: TestCase[] = [
128 | {
129 | name: 'uses legacy error message in the absence of the errors array',
130 | response: {
131 | body: { error: 'not authorized (legacy)' },
132 | status: 403,
133 | },
134 | expected: {
135 | formErrors: [
136 | {
137 | code: 'UNSPECIFIED',
138 | message: 'not authorized (legacy)',
139 | },
140 | ],
141 | fieldErrors: [],
142 | },
143 | },
144 | {
145 | name: 'ignores legacy error message in the presence of the errors array',
146 | response: {
147 | body: {
148 | error: '(legacy error message)',
149 | errors: [{ message: 'bad submission' }],
150 | },
151 | status: 400,
152 | },
153 | expected: {
154 | formErrors: [{ code: 'UNSPECIFIED', message: 'bad submission' }],
155 | fieldErrors: [],
156 | },
157 | },
158 | {
159 | name: 'produces FormError and FieldError(s) given the errors array',
160 | response: {
161 | body: {
162 | error: '(legacy error message)',
163 | errors: [
164 | {
165 | code: FieldErrorCodeEnum.TYPE_EMAIL,
166 | field: 'some-field',
167 | message: 'must be an email',
168 | },
169 | {
170 | code: FormErrorCodeEnum.EMPTY,
171 | message: 'empty form',
172 | },
173 | {
174 | code: FieldErrorCodeEnum.REQUIRED_FIELD_MISSING,
175 | field: 'some-other-field',
176 | message: 'field missing',
177 | },
178 | ],
179 | },
180 | status: 400,
181 | },
182 | expected: {
183 | formErrors: [
184 | { code: FormErrorCodeEnum.EMPTY, message: 'empty form' },
185 | ],
186 | fieldErrors: [
187 | [
188 | 'some-field',
189 | [
190 | {
191 | code: FieldErrorCodeEnum.TYPE_EMAIL,
192 | message: 'must be an email',
193 | },
194 | ],
195 | ],
196 | [
197 | 'some-other-field',
198 | [
199 | {
200 | code: FieldErrorCodeEnum.REQUIRED_FIELD_MISSING,
201 | message: 'field missing',
202 | },
203 | ],
204 | ],
205 | ],
206 | },
207 | },
208 | ];
209 |
210 | it.each(testCases)('$name', async ({ response, expected }) => {
211 | fetch.mockResolvedValue(
212 | new Response(JSON.stringify(response.body), {
213 | headers: { 'Content-Type': 'application/json' },
214 | status: response.status,
215 | })
216 | );
217 |
218 | const client = createClient();
219 | const data = {};
220 | const result = await client.submitForm('test-form-id', data);
221 |
222 | expect(result).toBeInstanceOf(SubmissionError);
223 | const errorResult = result as SubmissionError;
224 | expect(errorResult.getFormErrors()).toEqual(expected.formErrors);
225 | expect(errorResult.getAllFieldErrors()).toEqual(expected.fieldErrors);
226 | });
227 | });
228 |
229 | describe('when the server returns an unregonized response', () => {
230 | it('resolves to a SubmissionError result', async () => {
231 | fetch.mockResolvedValue(
232 | new Response(JSON.stringify({ something: '-' }), {
233 | headers: { 'Content-Type': 'application/json' },
234 | status: 400,
235 | })
236 | );
237 |
238 | const client = createClient();
239 | const data = { email: 'test@example.com' };
240 | const result = await client.submitForm('test-form-id', data);
241 |
242 | expect(result).toBeInstanceOf(SubmissionError);
243 | const errorResult = result as SubmissionError;
244 | expect(errorResult.getFormErrors()).toEqual([
245 | {
246 | code: 'UNSPECIFIED',
247 | message: 'Unexpected response format',
248 | },
249 | ]);
250 | expect(errorResult.getAllFieldErrors()).toEqual([]);
251 | });
252 | });
253 |
254 | describe('when fetch rejects', () => {
255 | describe('with an error', () => {
256 | it('resolves to a SubmissionError result', async () => {
257 | const errMessage = '(test) network error with an unknown reason';
258 | fetch.mockRejectedValue(new Error(errMessage));
259 |
260 | const client = createClient();
261 | const data = { email: 'test@example.com' };
262 | const result = await client.submitForm('test-form-id', {});
263 |
264 | expect(result).toBeInstanceOf(SubmissionError);
265 | const errorResult = result as SubmissionError;
266 | expect(errorResult.getFormErrors()).toEqual([
267 | {
268 | code: 'UNSPECIFIED',
269 | message: errMessage,
270 | },
271 | ]);
272 | expect(errorResult.getAllFieldErrors()).toEqual([]);
273 | });
274 | });
275 |
276 | describe('with an unknown value', () => {
277 | it('resolves to a SubmissionError result', async () => {
278 | fetch.mockRejectedValue({ someKey: 'some unknown value' });
279 |
280 | const client = createClient();
281 | const data = { email: 'test@example.com' };
282 | const result = await client.submitForm('test-form-id', {});
283 |
284 | expect(result).toBeInstanceOf(SubmissionError);
285 | const errorResult = result as SubmissionError;
286 | expect(errorResult.getFormErrors()).toEqual([
287 | {
288 | code: 'UNSPECIFIED',
289 | message:
290 | 'Unknown error while posting to Formspree: {"someKey":"some unknown value"}',
291 | },
292 | ]);
293 | expect(errorResult.getAllFieldErrors()).toEqual([]);
294 | });
295 | });
296 | });
297 |
298 | describe('when the server returns a success response', () => {
299 | const responseBody = { next: 'test-redirect-url' };
300 |
301 | beforeEach(() => {
302 | fetch.mockResolvedValue(
303 | new Response(JSON.stringify(responseBody), {
304 | headers: { 'Content-Type': 'application/json' },
305 | })
306 | );
307 | });
308 |
309 | it('resolves to a SubmissionSuccess', async () => {
310 | const client = createClient();
311 | const data = { email: 'test@example.com' };
312 | const result = await client.submitForm('test-form-id', data);
313 |
314 | expect(result).toBeInstanceOf(SubmissionSuccess);
315 | const successResult = result as SubmissionSuccess;
316 | expect(successResult.kind).toBe('success');
317 | expect(successResult.next).toEqual(responseBody.next);
318 | });
319 | });
320 |
321 | describe('with Stripe', () => {
322 | function createClientWithStripe(
323 | handleCardAction?: Stripe['handleCardAction']
324 | ): Client {
325 | const stripe = { handleCardAction } as Stripe;
326 | return createClient({ stripe });
327 | }
328 |
329 | describe('when payment method creation fails', () => {
330 | async function createPaymentMethod(): Promise {
331 | return { error: { type: 'card_error' as const } };
332 | }
333 |
334 | it('returns an error result', async () => {
335 | const client = createClientWithStripe();
336 | const data = { email: 'test@example.com' };
337 | const result = await client.submitForm('test-form-id', data, {
338 | createPaymentMethod,
339 | });
340 |
341 | expect(result).toBeInstanceOf(SubmissionError);
342 | const errorResult = result as SubmissionError;
343 | expect(errorResult.getFormErrors()).toEqual([]);
344 | expect(errorResult.getAllFieldErrors()).toEqual([
345 | [
346 | 'paymentMethod',
347 | [
348 | {
349 | code: 'STRIPE_CLIENT_ERROR',
350 | message: 'Error creating payment method',
351 | },
352 | ],
353 | ],
354 | ]);
355 | });
356 | });
357 |
358 | describe('when payment method creation succeeds', () => {
359 | async function createPaymentMethod(): Promise {
360 | return {
361 | paymentMethod: { id: 'test-payment-method-id' },
362 | } as PaymentMethodResult;
363 | }
364 |
365 | describe('and payment submission fails', () => {
366 | it('returns SubmissionError', async () => {
367 | fetch.mockResolvedValueOnce(
368 | new Response(
369 | JSON.stringify({
370 | error: '(legacy error message)',
371 | errors: [{ message: 'bad submission' }],
372 | } satisfies ServerErrorResponse)
373 | )
374 | );
375 |
376 | const client = createClientWithStripe();
377 | const data = { email: 'test@example.com' };
378 | const result = await client.submitForm('test-form-id', data, {
379 | createPaymentMethod,
380 | });
381 |
382 | expect(fetch).toHaveBeenCalledTimes(1);
383 | expect(fetch).toHaveBeenLastCalledWith(
384 | 'https://formspree.io/f/test-form-id',
385 | {
386 | body: JSON.stringify({
387 | email: 'test@example.com',
388 | paymentMethod: 'test-payment-method-id',
389 | }),
390 | headers: {
391 | Accept: 'application/json',
392 | 'Content-Type': 'application/json',
393 | 'Formspree-Client': `@formspree/core@${version}`,
394 | 'Formspree-Session-Data': expectedSessionData,
395 | },
396 | method: 'POST',
397 | mode: 'cors',
398 | }
399 | );
400 |
401 | expect(result).toBeInstanceOf(SubmissionError);
402 | const errorResult = result as SubmissionError;
403 | expect(errorResult.getFormErrors()).toEqual([
404 | {
405 | code: 'UNSPECIFIED',
406 | message: 'bad submission',
407 | },
408 | ]);
409 | expect(errorResult.getAllFieldErrors()).toEqual([]);
410 | });
411 | });
412 |
413 | describe('and payment submission succeeds', () => {
414 | it('returns SubmissionSuccess', async () => {
415 | const responseBody = { next: 'test-redirect-url' };
416 | fetch.mockResolvedValueOnce(
417 | new Response(JSON.stringify(responseBody))
418 | );
419 |
420 | const client = createClientWithStripe();
421 | const data = { email: 'test@example.com' };
422 | const result = await client.submitForm('test-form-id', data, {
423 | createPaymentMethod,
424 | });
425 |
426 | expect(fetch).toHaveBeenCalledTimes(1);
427 | expect(fetch).toHaveBeenLastCalledWith(
428 | 'https://formspree.io/f/test-form-id',
429 | {
430 | body: JSON.stringify({
431 | email: 'test@example.com',
432 | paymentMethod: 'test-payment-method-id',
433 | }),
434 | headers: {
435 | Accept: 'application/json',
436 | 'Content-Type': 'application/json',
437 | 'Formspree-Client': `@formspree/core@${version}`,
438 | 'Formspree-Session-Data': expectedSessionData,
439 | },
440 | method: 'POST',
441 | mode: 'cors',
442 | }
443 | );
444 |
445 | expect(result).toBeInstanceOf(SubmissionSuccess);
446 | const successResult = result as SubmissionSuccess;
447 | expect(successResult.kind).toBe('success');
448 | expect(successResult.next).toEqual(responseBody.next);
449 | });
450 | });
451 |
452 | describe('and payment submission requires SCA', () => {
453 | class RequireSCAResponse extends Response {
454 | constructor() {
455 | super(
456 | JSON.stringify({
457 | resubmitKey: 'test-resubmit-key',
458 | stripe: {
459 | paymentIntentClientSecret:
460 | 'test-payment-intent-client-secret',
461 | },
462 | } satisfies ServerStripeSCAPendingResponse)
463 | );
464 | }
465 | }
466 |
467 | describe('and Stripe handleCardAction fails', () => {
468 | async function handleCardAction(): Promise {
469 | return { error: { type: 'card_error' } };
470 | }
471 |
472 | it('returns SubmissionError', async () => {
473 | fetch.mockResolvedValueOnce(new RequireSCAResponse());
474 |
475 | const client = createClientWithStripe(handleCardAction);
476 | const data = { email: 'test@example.com' };
477 | const result = await client.submitForm('test-form-id', data, {
478 | createPaymentMethod,
479 | });
480 |
481 | expect(fetch).toHaveBeenCalledTimes(1);
482 | expect(fetch).toHaveBeenLastCalledWith(
483 | 'https://formspree.io/f/test-form-id',
484 | {
485 | body: JSON.stringify({
486 | email: 'test@example.com',
487 | paymentMethod: 'test-payment-method-id',
488 | }),
489 | headers: {
490 | Accept: 'application/json',
491 | 'Content-Type': 'application/json',
492 | 'Formspree-Client': `@formspree/core@${version}`,
493 | 'Formspree-Session-Data': expectedSessionData,
494 | },
495 | method: 'POST',
496 | mode: 'cors',
497 | }
498 | );
499 |
500 | expect(result).toBeInstanceOf(SubmissionError);
501 | const errorResult = result as SubmissionError;
502 | expect(errorResult.getFormErrors()).toEqual([]);
503 | expect(errorResult.getAllFieldErrors()).toEqual([
504 | [
505 | 'paymentMethod',
506 | [
507 | {
508 | code: 'STRIPE_CLIENT_ERROR',
509 | message: 'Stripe SCA error',
510 | },
511 | ],
512 | ],
513 | ]);
514 | });
515 | });
516 |
517 | describe('and Stripe handleCardAction succeeds (FormData)', () => {
518 | async function handleCardAction(): Promise {
519 | return {
520 | paymentIntent: { id: 'test-payment-intent-id' },
521 | } as PaymentIntentResult;
522 | }
523 |
524 | it('resubmits the form and produces a SubmissionSuccess', async () => {
525 | const responseBody = { next: 'test-redirect-url' };
526 |
527 | fetch
528 | .mockResolvedValueOnce(new RequireSCAResponse())
529 | .mockResolvedValueOnce(
530 | new Response(JSON.stringify(responseBody))
531 | );
532 |
533 | const client = createClientWithStripe(handleCardAction);
534 | const data = new FormData();
535 | data.set('email', 'test@example.com');
536 | data.set('message', 'Hello!');
537 | // support files
538 | data.set(
539 | 'attachment',
540 | new Blob(['fake-image-content'], { type: 'image/jpeg' })
541 | );
542 | const result = await client.submitForm('test-form-id', data, {
543 | createPaymentMethod,
544 | });
545 |
546 | expect(fetch).toHaveBeenCalledTimes(2);
547 | expect(fetch).toHaveBeenNthCalledWith(
548 | 1,
549 | 'https://formspree.io/f/test-form-id',
550 | {
551 | body: data,
552 | headers: {
553 | Accept: 'application/json',
554 | 'Formspree-Client': `@formspree/core@${version}`,
555 | 'Formspree-Session-Data': expectedSessionData,
556 | },
557 | method: 'POST',
558 | mode: 'cors',
559 | }
560 | );
561 | expect(fetch).toHaveBeenNthCalledWith(
562 | 2,
563 | 'https://formspree.io/f/test-form-id',
564 | {
565 | body: data,
566 | headers: {
567 | Accept: 'application/json',
568 | 'Formspree-Client': `@formspree/core@${version}`,
569 | 'Formspree-Session-Data': expectedSessionData,
570 | },
571 | method: 'POST',
572 | mode: 'cors',
573 | }
574 | );
575 |
576 | expect(result).toBeInstanceOf(SubmissionSuccess);
577 | const successResult = result as SubmissionSuccess;
578 | expect(successResult.kind).toBe('success');
579 | expect(successResult.next).toEqual(responseBody.next);
580 | });
581 | });
582 |
583 | describe('and Stripe handleCardAction succeeds (plain object)', () => {
584 | async function handleCardAction(): Promise {
585 | return {
586 | paymentIntent: { id: 'test-payment-intent-id' },
587 | } as PaymentIntentResult;
588 | }
589 |
590 | it('resubmits the form and produces a SubmissionSuccess', async () => {
591 | const responseBody = { next: 'test-redirect-url' };
592 |
593 | fetch
594 | .mockResolvedValueOnce(new RequireSCAResponse())
595 | .mockResolvedValueOnce(
596 | new Response(JSON.stringify(responseBody))
597 | );
598 |
599 | const client = createClientWithStripe(handleCardAction);
600 | const data = { email: 'test@example.com' };
601 | const result = await client.submitForm('test-form-id', data, {
602 | createPaymentMethod,
603 | });
604 |
605 | expect(fetch).toHaveBeenCalledTimes(2);
606 | expect(fetch).toHaveBeenNthCalledWith(
607 | 1,
608 | 'https://formspree.io/f/test-form-id',
609 | {
610 | body: JSON.stringify({
611 | email: 'test@example.com',
612 | paymentMethod: 'test-payment-method-id',
613 | }),
614 | headers: {
615 | Accept: 'application/json',
616 | 'Content-Type': 'application/json',
617 | 'Formspree-Client': `@formspree/core@${version}`,
618 | 'Formspree-Session-Data': expectedSessionData,
619 | },
620 | method: 'POST',
621 | mode: 'cors',
622 | }
623 | );
624 | expect(fetch).toHaveBeenNthCalledWith(
625 | 2,
626 | 'https://formspree.io/f/test-form-id',
627 | {
628 | body: JSON.stringify({
629 | email: 'test@example.com',
630 | // paymentMethod is deleted
631 | paymentIntent: 'test-payment-intent-id',
632 | resubmitKey: 'test-resubmit-key',
633 | }),
634 | headers: {
635 | Accept: 'application/json',
636 | 'Content-Type': 'application/json',
637 | 'Formspree-Client': `@formspree/core@${version}`,
638 | 'Formspree-Session-Data': expectedSessionData,
639 | },
640 | method: 'POST',
641 | mode: 'cors',
642 | }
643 | );
644 |
645 | expect(result).toBeInstanceOf(SubmissionSuccess);
646 | const successResult = result as SubmissionSuccess;
647 | expect(successResult.kind).toBe('success');
648 | expect(successResult.next).toEqual(responseBody.next);
649 | });
650 | });
651 | });
652 | });
653 | });
654 | });
655 |
--------------------------------------------------------------------------------
/packages/formspree-core/test/session.test.ts:
--------------------------------------------------------------------------------
1 | import { Session } from '../src/session';
2 |
3 | describe('Session', () => {
4 | const now = Date.now();
5 |
6 | beforeEach(() => {
7 | jest.useFakeTimers({ now });
8 | });
9 |
10 | afterEach(() => {
11 | jest.useRealTimers();
12 | });
13 |
14 | describe('with webdriver', () => {
15 | beforeEach(() => {
16 | // pretend running in PhantomJS
17 | window._phantom = {};
18 | });
19 |
20 | afterEach(() => {
21 | window._phantom = undefined;
22 | });
23 |
24 | it('returns the correct data', () => {
25 | const sess = new Session();
26 | expect(sess.data()).toEqual({
27 | loadedAt: now,
28 | webdriver: true,
29 | });
30 | });
31 | });
32 |
33 | describe('without webdriver', () => {
34 | it('returns the correct data', () => {
35 | const sess = new Session();
36 | expect(sess.data()).toEqual({
37 | loadedAt: now,
38 | webdriver: false,
39 | });
40 | });
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/packages/formspree-core/test/submission.test.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FieldErrorCodeEnum,
3 | FormErrorCodeEnum,
4 | SubmissionError,
5 | } from '../src/submission';
6 |
7 | describe('SubmissionError', () => {
8 | test('no server errors', () => {
9 | const err = new SubmissionError();
10 | expect(err.getFormErrors()).toEqual([]);
11 | expect(err.getFieldErrors('')).toEqual([]);
12 | expect(err.getFieldErrors('some-key')).toEqual([]);
13 | expect(err.getAllFieldErrors()).toEqual([]);
14 | });
15 |
16 | test('with one form error', () => {
17 | const err = new SubmissionError({ message: '(test) unknown error' });
18 | expect(err.getFormErrors()).toEqual([
19 | {
20 | code: 'UNSPECIFIED',
21 | message: '(test) unknown error',
22 | },
23 | ]);
24 | expect(err.getFieldErrors('')).toEqual([]);
25 | expect(err.getFieldErrors('some-key')).toEqual([]);
26 | expect(err.getAllFieldErrors()).toEqual([]);
27 | });
28 |
29 | test('with one form error, with code', () => {
30 | const err = new SubmissionError({
31 | code: FormErrorCodeEnum.EMPTY,
32 | message: '(test) empty form',
33 | });
34 | expect(err.getFormErrors()).toEqual([
35 | {
36 | code: FormErrorCodeEnum.EMPTY,
37 | message: '(test) empty form',
38 | },
39 | ]);
40 | expect(err.getFieldErrors('')).toEqual([]);
41 | expect(err.getFieldErrors('some-key')).toEqual([]);
42 | expect(err.getAllFieldErrors()).toEqual([]);
43 | });
44 |
45 | test('with one field error', () => {
46 | const err = new SubmissionError({
47 | field: 'some-key',
48 | message: '(test) the field is required',
49 | });
50 | expect(err.getFormErrors()).toEqual([]);
51 | expect(err.getFieldErrors('some-key')).toEqual([
52 | {
53 | code: 'UNSPECIFIED',
54 | message: '(test) the field is required',
55 | },
56 | ]);
57 | expect(err.getAllFieldErrors()).toEqual([
58 | [
59 | 'some-key',
60 | [
61 | {
62 | code: 'UNSPECIFIED',
63 | message: '(test) the field is required',
64 | },
65 | ],
66 | ],
67 | ]);
68 | });
69 |
70 | test('with one field error, with code', () => {
71 | const err = new SubmissionError({
72 | code: FieldErrorCodeEnum.REQUIRED_FIELD_EMPTY,
73 | field: 'some-key',
74 | message: '(test) the field is required',
75 | });
76 | expect(err.getFormErrors()).toEqual([]);
77 | expect(err.getFieldErrors('some-key')).toEqual([
78 | {
79 | code: FieldErrorCodeEnum.REQUIRED_FIELD_EMPTY,
80 | message: '(test) the field is required',
81 | },
82 | ]);
83 | expect(err.getAllFieldErrors()).toEqual([
84 | [
85 | 'some-key',
86 | [
87 | {
88 | code: FieldErrorCodeEnum.REQUIRED_FIELD_EMPTY,
89 | message: '(test) the field is required',
90 | },
91 | ],
92 | ],
93 | ]);
94 | });
95 |
96 | test('with a mix of a form error and multiple field errors', () => {
97 | const err = new SubmissionError(
98 | {
99 | message: '(test) unknown form error',
100 | },
101 | {
102 | code: FieldErrorCodeEnum.REQUIRED_FIELD_EMPTY,
103 | field: 'some-key',
104 | message: '(test) the field is required',
105 | },
106 | {
107 | code: FormErrorCodeEnum.EMPTY,
108 | message: '(test) empty form',
109 | },
110 | {
111 | code: FieldErrorCodeEnum.TYPE_EMAIL,
112 | field: 'some-other-key',
113 | message: '(test) should be an email',
114 | },
115 | {
116 | code: FieldErrorCodeEnum.TYPE_TEXT,
117 | field: 'some-key',
118 | message: '(test) should be a text',
119 | }
120 | );
121 | expect(err.getFormErrors()).toEqual([
122 | {
123 | code: 'UNSPECIFIED',
124 | message: '(test) unknown form error',
125 | },
126 | {
127 | code: FormErrorCodeEnum.EMPTY,
128 | message: '(test) empty form',
129 | },
130 | ]);
131 | expect(err.getFieldErrors('some-key')).toEqual([
132 | {
133 | code: FieldErrorCodeEnum.REQUIRED_FIELD_EMPTY,
134 | message: '(test) the field is required',
135 | },
136 | {
137 | code: FieldErrorCodeEnum.TYPE_TEXT,
138 | message: '(test) should be a text',
139 | },
140 | ]);
141 | expect(err.getFieldErrors('some-other-key')).toEqual([
142 | {
143 | code: FieldErrorCodeEnum.TYPE_EMAIL,
144 | message: '(test) should be an email',
145 | },
146 | ]);
147 | expect(err.getAllFieldErrors()).toEqual([
148 | [
149 | 'some-key',
150 | [
151 | {
152 | code: FieldErrorCodeEnum.REQUIRED_FIELD_EMPTY,
153 | message: '(test) the field is required',
154 | },
155 | {
156 | code: FieldErrorCodeEnum.TYPE_TEXT,
157 | message: '(test) should be a text',
158 | },
159 | ],
160 | ],
161 | [
162 | 'some-other-key',
163 | [
164 | {
165 | code: FieldErrorCodeEnum.TYPE_EMAIL,
166 | message: '(test) should be an email',
167 | },
168 | ],
169 | ],
170 | ]);
171 | });
172 | });
173 |
--------------------------------------------------------------------------------
/packages/formspree-core/test/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { appendExtraData } from '../src/utils';
2 |
3 | describe('appendExtraData', () => {
4 | describe('given a plain object input', () => {
5 | type TestCase = {
6 | name: string;
7 | input: {
8 | formData: Record;
9 | prop: string;
10 | value: string;
11 | };
12 | expected: Record;
13 | };
14 |
15 | const testCases: TestCase[] = [
16 | {
17 | name: 'empty',
18 | input: {
19 | formData: {},
20 | prop: 'foo',
21 | value: 'bar',
22 | },
23 | expected: { foo: 'bar' },
24 | },
25 | {
26 | name: 'with some existing values',
27 | input: {
28 | formData: { a: '1', b: '2' },
29 | prop: 'foo',
30 | value: 'bar',
31 | },
32 | expected: { a: '1', b: '2', foo: 'bar' },
33 | },
34 | {
35 | name: 'with an existing value for the same prop',
36 | input: {
37 | formData: { foo: '(existing)' },
38 | prop: 'foo',
39 | value: 'bar',
40 | },
41 | expected: { foo: 'bar' },
42 | },
43 | ];
44 |
45 | test.each(testCases)('$name', ({ input, expected }) => {
46 | const { formData, prop, value } = input;
47 | appendExtraData(formData, prop, value);
48 | expect(formData).toEqual(expected);
49 | });
50 | });
51 |
52 | describe('given a FormData input', () => {
53 | type TestCase = {
54 | name: string;
55 | input: {
56 | formData: FormData;
57 | prop: string;
58 | value: string;
59 | };
60 | expectedEntries: [string, string][];
61 | };
62 |
63 | const testCases: TestCase[] = [
64 | {
65 | name: 'empty',
66 | input: {
67 | formData: createFormData({}),
68 | prop: 'foo',
69 | value: 'bar',
70 | },
71 | expectedEntries: [['foo', 'bar']],
72 | },
73 | {
74 | name: 'with some existing values',
75 | input: {
76 | formData: createFormData({ a: '1', b: '2' }),
77 | prop: 'foo',
78 | value: 'bar',
79 | },
80 | expectedEntries: [
81 | ['a', '1'],
82 | ['b', '2'],
83 | ['foo', 'bar'],
84 | ],
85 | },
86 | {
87 | name: 'with an existing value for the same prop',
88 | input: {
89 | formData: createFormData({ foo: '(existing)' }),
90 | prop: 'foo',
91 | value: 'bar',
92 | },
93 | expectedEntries: [
94 | ['foo', '(existing)'],
95 | ['foo', 'bar'],
96 | ],
97 | },
98 | ];
99 |
100 | test.each(testCases)('$name', ({ input, expectedEntries }) => {
101 | const { formData, prop, value } = input;
102 | appendExtraData(formData, prop, value);
103 | // convert FormData to an array of entries for better comparison
104 | expect(Array.from(formData)).toEqual(expectedEntries);
105 | });
106 | });
107 | });
108 |
109 | // createFormData creates a new instance of FormData and initializes it with init.
110 | function createFormData(init: Record): FormData {
111 | const formData = new FormData();
112 | for (const [k, v] of Object.entries(init)) {
113 | formData.set(k, v);
114 | }
115 | return formData;
116 | }
117 |
--------------------------------------------------------------------------------
/packages/formspree-core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "@formspree/core",
4 | "extends": ["../../tsconfig.json"],
5 | "compilerOptions": {
6 | "declaration": true,
7 | "declarationDir": "dist/types",
8 | "declarationMap": true,
9 | "resolveJsonModule": true
10 | },
11 | "include": ["src/**/*", "test/**/*"],
12 | "exclude": ["node_modules", "dist"],
13 | "files": ["./package.json"]
14 | }
15 |
--------------------------------------------------------------------------------
/packages/formspree-react/.babelrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "test": {
4 | "presets": [
5 | "@babel/preset-env",
6 | "@babel/preset-react",
7 | "@babel/preset-typescript"
8 | ]
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/formspree-react/.eslintrc.yml:
--------------------------------------------------------------------------------
1 | root: true
2 | extends:
3 | - '../../.eslintrc.yml'
4 | - plugin:react-hooks/recommended
5 | ignorePatterns:
6 | - jest.config.js
7 | - jest.setup.js
8 | - dist/
9 | parserOptions:
10 | project: './tsconfig.json'
11 |
--------------------------------------------------------------------------------
/packages/formspree-react/.lintstagedrc.yml:
--------------------------------------------------------------------------------
1 | package.json: yarn sort-package-json
2 | '*.{json,md,yml}': yarn prettier --write
3 | '*.{js,jsx,ts,tsx}':
4 | - yarn prettier --write
5 | # calling directly to eslint binary this instead of doing `yarn lint`
6 | # because lint-staged will pass the list of files
7 | - yarn eslint
8 |
--------------------------------------------------------------------------------
/packages/formspree-react/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 3.0.0
4 |
5 | ### Major Changes
6 |
7 | - a00b642: Support React 19 and stripe-js 5
8 |
9 | ### Patch Changes
10 |
11 | - Updated dependencies [a00b642]
12 | - @formspree/core@4.0.0
13 |
14 | ## 2.5.5
15 |
16 | ### Patch Changes
17 |
18 | - 6739c4c: Fix: Stripe not working on @formspree/react
19 |
20 | ## 2.5.4
21 |
22 | ### Patch Changes
23 |
24 | - ceaae3d: Bump (patch) @formspree/core and @formspree/react to verify that the release pipeline is fixed
25 | - Updated dependencies [ceaae3d]
26 | - @formspree/core@3.0.4
27 |
28 | ## 2.5.3
29 |
30 | ### Patch Changes
31 |
32 | - 805aa4d: Bump (patch) @formspree/core and @formspree/react to fix the release pipeline
33 | - Updated dependencies [805aa4d]
34 | - @formspree/core@3.0.3
35 |
36 | ## 2.5.2
37 |
38 | ### Patch Changes
39 |
40 | - 6c31cd1: (type) Support array as FieldValue
41 | - Updated dependencies [6c31cd1]
42 | - @formspree/core@3.0.2
43 |
44 | ## 2.5.1
45 |
46 | ### Patch Changes
47 |
48 | - 56a444b: remove unused package-specific yarn.lock
49 | - Updated dependencies [56a444b]
50 | - @formspree/core@3.0.1
51 |
52 | ## 2.5.0
53 |
54 | ### Minor Changes
55 |
56 | - 4c40e1b: # Fix types in @formspree/core
57 |
58 | ## `@formspree/core`
59 |
60 | - fix `SubmissionData` has a type of `any` causing everything after it to opt-out typechecking
61 | - remove a no-op `teardown` method on `Client` and `Session`
62 | - remove `utils.now` and use `Date.now` instead
63 | - remove unused functions from `utils` module: `append`, `toCamel`, `camelizeTopKeys`
64 | - add tests for `utils.appendExtraData` and convert the test file to typescript
65 | - add tests for `session.data()`
66 | - no longer export `Session` type
67 |
68 | ## `@formspree/react`
69 |
70 | - update types as a result of `SubmissionData` is no longer `any`
71 | - fix `createPaymentMethod` does not properly map payload when the submission data is a type of `FormData`
72 | - fix the `Client` is not updated when project changes
73 |
74 | - 49730d9: ## Improve error handling
75 |
76 | - `@formspree/core` `submitForm` function now will never rejects but always produces a type of `SubmissionResult`, different types of the result can be refined/narrowed down using the field `kind`.
77 | - Provide `SubmissionErrorResult` which can be used to get an array of form errors and/or field errors (by field name)
78 | - `Response` is no longer made available on the submission result
79 | - Update `@formspree/react` for the changes introduced to `@formspree/core`
80 |
81 | - d025831: `@formspree/core`
82 |
83 | - rename client config `stripePromise` to `stripe` since it expects the resolved Stripe client not a promise
84 |
85 | `@formspree/react`
86 |
87 | - add a new hook: `useSubmit` which is suitable with code that uses other ways to manage submission state (e.g. with a library like react-hook-form)
88 | - update `useForm` to use `useSubmit` under the hood
89 | - fix: `FormspreeContext` updates the client when `props.project` change
90 |
91 | ### Patch Changes
92 |
93 | - Updated dependencies [4c40e1b]
94 | - Updated dependencies [49730d9]
95 | - Updated dependencies [d025831]
96 | - @formspree/core@3.0.0
97 |
98 | ## 2.4.4
99 |
100 | ### Patch Changes
101 |
102 | - a359edd: Upgrade jest to v29 using centralized dependency, and run tests in CI
103 | - Updated dependencies [a359edd]
104 | - @formspree/core@2.8.3
105 |
106 | ## 2.4.3
107 |
108 | ### Patch Changes
109 |
110 | - Unify typescript version and enforce typechecking
111 |
112 | - Centralize typescript and its related `devDependencies` to project root for version consistency
113 | - Centralize `tsconfig` to root and have package-specific `tsconfig` extends it for consistency
114 | - Make typescript config more strict especially around detecting `null` and `undefined`
115 | - Run typecheck on test folders
116 | - Fix type errors
117 | - Add Turbo `typecheck` task which runs `tsc` in typechecking mode
118 | - Set up Github Action to run `typecheck` task
119 |
120 | - Updated dependencies [07c30c5]
121 | - @formspree/core@2.8.2
122 |
123 | ## 2.4.2
124 |
125 | ### Patch Changes
126 |
127 | - 758b606: Upgrading react version used for development. Making react peerDependency explicit. Fixing dependency on core.
128 |
129 | ## 2.4.1
130 |
131 | ### Patch Changes
132 |
133 | - Fixed promise detection in extradata
134 | - Better error handling
135 |
136 | ## 2.4.0
137 |
138 | ### Minor Changes
139 |
140 | - Conversion to monorepo. Path for type imports changed. Lazy loading stripe.
141 |
142 | ## 2.1.1
143 |
144 | - Update dependencies for security.
145 |
146 | ## 2.1.0
147 |
148 | - Update core library.
149 |
150 | ## 2.0.1
151 |
152 | - Bug fix: add indexer to ValidationError props type. This will allow any additional props to pass-through without TypeScript having an issue with it.
153 |
154 | ## 2.0.0
155 |
156 | - Migrate to TypeScript
157 | - Add `StaticKitProvider` and `useStaticKit` hook for consuming context
158 | - **Breaking change**: New argument structure for the `useForm` hook:
159 |
160 | ```js
161 | const [state, handleSubmit] = useForm(formKey, opts);
162 | ```
163 |
164 | ## 1.2.0
165 |
166 | - Update core to fix body serialization bug.
167 | - Pass along `clientName` with form submission.
168 |
169 | ## 1.1.2
170 |
171 | - Bug fix: an undeclared variable was referenced when `data` values were functions.
172 |
173 | ## 1.1.1
174 |
175 | - Accept `data` property for adding programmatic fields to the form payload.
176 |
177 | ## 1.1.0
178 |
179 | - Accept `site` + `form` combo (in lieu of `id`) for identifying forms.
180 |
181 | ## 1.0.1
182 |
183 | - Bundle iife for testing in browser.
184 |
185 | ## 1.0.0
186 |
187 | - Refactor npm packaging and add tests.
188 |
189 | ## 1.0.0-beta.7
190 |
191 | - Use `useRef` internally to store the StaticKit client.
192 |
193 | ## 1.0.0-beta.6
194 |
195 | - Bug fix with form component teardown.
196 |
197 | ## 1.0.0-beta.5
198 |
199 | - Update StaticKit Core to prevent messing with `window` object.
200 |
201 | ## 1.0.0-beta.4
202 |
203 | - Update StaticKit Core.
204 | - Teardown the client when form components are unmounted.
205 |
206 | ## 1.0.0-beta.3
207 |
208 | Use the `@statickit/core` client library.
209 |
210 | Also, rework the `useForm` arg structure to accomodate more options:
211 |
212 | ```diff
213 | - const [state, submit] = useForm('XXXXXXXX')
214 | + const [state, submit] = useForm({ id: 'XXXXXXXX' })
215 | ```
216 |
217 | We've retained backward-compatibility.
218 |
219 | ## 1.0.0-beta.2
220 |
221 | Renamed the UMD global export from `statickit-react` to `StaticKitReact`.
222 |
223 | ## 1.0.0-beta.1
224 |
225 | Wrap state variables up in a `state` object in the return value for `useForm`:
226 |
227 | ```javascript
228 | const [state, submit] = useForm('xyz');
229 | ```
230 |
231 | ## 1.0.0-beta.0
232 |
233 | Initial release.
234 |
--------------------------------------------------------------------------------
/packages/formspree-react/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Level Technologies, LLC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/formspree-react/README.md:
--------------------------------------------------------------------------------
1 | # Formspree React
2 |
3 | The React component library for [Formspree](https://formspree.io).
4 |
5 | ## Help and Support
6 |
7 | For help and support please see [the Formspree React docs](https://help.formspree.io/hc/en-us/articles/360055613373).
8 |
--------------------------------------------------------------------------------
/packages/formspree-react/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('jest').Config} */
2 | const config = {
3 | setupFilesAfterEnv: ['/jest.setup.js'],
4 | testEnvironment: 'jsdom',
5 | };
6 |
7 | module.exports = config;
8 |
--------------------------------------------------------------------------------
/packages/formspree-react/jest.setup.js:
--------------------------------------------------------------------------------
1 | // Fix: ReferenceError: Response is not defined
2 | import 'isomorphic-fetch';
3 |
--------------------------------------------------------------------------------
/packages/formspree-react/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@formspree/react",
3 | "version": "3.0.0",
4 | "private": false,
5 | "description": "The React component library for Formspree",
6 | "bugs": {
7 | "url": "https://github.com/formspree/formspree-react/issues"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/formspree/formspree-react.git"
12 | },
13 | "license": "MIT",
14 | "contributors": [
15 | "Derrick Reimer",
16 | "Cole Krumbholz",
17 | "Ismail Ghallou"
18 | ],
19 | "sideEffects": false,
20 | "main": "./dist/index.js",
21 | "module": "./dist/index.mjs",
22 | "types": "./dist/index.d.ts",
23 | "files": [
24 | "dist/**"
25 | ],
26 | "scripts": {
27 | "build": "tsup src/index.ts --format esm,cjs --dts --external react --minify",
28 | "clean": "rm -rf dist && rm -rf node_modules",
29 | "dev": "tsup src/index.ts --format esm,cjs --dts --external react --sourcemap --watch",
30 | "lint": "eslint ./src ./test",
31 | "test": "jest",
32 | "typecheck": "tsc --noEmit"
33 | },
34 | "dependencies": {
35 | "@formspree/core": "^4.0.0",
36 | "@stripe/react-stripe-js": "^3.1.1",
37 | "@stripe/stripe-js": "^5.7.0"
38 | },
39 | "devDependencies": {
40 | "@babel/preset-react": "^7.22.5",
41 | "@swc/core": "^1.3.61",
42 | "@testing-library/dom": "^9.3.0",
43 | "@testing-library/react": "^16.2.0",
44 | "@testing-library/user-event": "^14.4.3",
45 | "@types/react": "^19.0.0",
46 | "@types/react-dom": "^19.0.0",
47 | "eslint-plugin-react-hooks": "^4.3.0",
48 | "react": "^19.0.0",
49 | "react-dom": "^19.0.0"
50 | },
51 | "peerDependencies": {
52 | "react": "^16.8 || ^17.0 || ^18.0 || ^19.0",
53 | "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0"
54 | },
55 | "publishConfig": {
56 | "access": "public",
57 | "registry": "https://registry.npmjs.org/"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/packages/formspree-react/src/ValidationError.tsx:
--------------------------------------------------------------------------------
1 | import React, { type ComponentPropsWithoutRef } from 'react';
2 | import type { FieldValues, SubmissionError } from '@formspree/core';
3 |
4 | export type ValidationErrorProps = {
5 | errors: SubmissionError | null;
6 | field?: keyof T;
7 | prefix?: string;
8 | } & ComponentPropsWithoutRef<'div'>;
9 |
10 | export function ValidationError(
11 | props: ValidationErrorProps
12 | ) {
13 | const { prefix, field, errors, ...attrs } = props;
14 | if (errors == null) {
15 | return null;
16 | }
17 |
18 | const errs = field ? errors.getFieldErrors(field) : errors.getFormErrors();
19 | if (errs.length === 0) {
20 | return null;
21 | }
22 |
23 | return (
24 |
25 | {prefix ? `${prefix} ` : null}
26 | {errs.map((err) => err.message).join(', ')}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/packages/formspree-react/src/context.tsx:
--------------------------------------------------------------------------------
1 | import { createClient, getDefaultClient, type Client } from '@formspree/core';
2 | import { Elements } from '@stripe/react-stripe-js';
3 | import { loadStripe } from '@stripe/stripe-js/pure/index.js';
4 | import React, {
5 | useContext,
6 | useEffect,
7 | useMemo,
8 | useState,
9 | type ReactNode,
10 | } from 'react';
11 | import { StripeProvider } from './stripe';
12 |
13 | export type FormspreeContextType = {
14 | client: Client;
15 | };
16 |
17 | export type FormspreeProviderProps = {
18 | children: ReactNode;
19 | project?: string;
20 | stripePK?: string;
21 | };
22 |
23 | const FormspreeContext = React.createContext(null);
24 |
25 | /**
26 | * FormspreeProvider creates Formspree Client based on the given props
27 | * and makes the client available via context.
28 | */
29 | export function FormspreeProvider(props: FormspreeProviderProps) {
30 | const { children, project, stripePK } = props;
31 | const [client, setClient] = useState(createClient({ project }));
32 | const stripePromise = useMemo(
33 | () => (stripePK ? loadStripe(stripePK) : null),
34 | [stripePK]
35 | );
36 |
37 | useEffect(() => {
38 | let isMounted = true;
39 | if (isMounted) {
40 | setClient((client) =>
41 | client.project !== project
42 | ? createClient({ ...client, project })
43 | : client
44 | );
45 | }
46 | return () => {
47 | isMounted = false;
48 | };
49 | }, [project]);
50 |
51 | useEffect(() => {
52 | let isMounted = true;
53 | stripePromise?.then((stripe) => {
54 | if (isMounted && stripe) {
55 | setClient((client) => createClient({ ...client, stripe }));
56 | }
57 | });
58 | return () => {
59 | isMounted = false;
60 | };
61 | }, [stripePromise]);
62 |
63 | return (
64 |
65 | {stripePromise ? (
66 |
67 | {children}
68 |
69 | ) : (
70 | children
71 | )}
72 |
73 | );
74 | }
75 |
76 | export function useFormspree(): FormspreeContextType {
77 | return useContext(FormspreeContext) ?? { client: getDefaultClient() };
78 | }
79 |
--------------------------------------------------------------------------------
/packages/formspree-react/src/index.ts:
--------------------------------------------------------------------------------
1 | export { CardElement } from '@stripe/react-stripe-js';
2 | export { ValidationError, type ValidationErrorProps } from './ValidationError';
3 | export {
4 | FormspreeProvider,
5 | useFormspree,
6 | type FormspreeContextType,
7 | type FormspreeProviderProps,
8 | } from './context';
9 | export * from './types';
10 | export { useForm, type TUseForm } from './useForm';
11 | export { useSubmit } from './useSubmit';
12 |
--------------------------------------------------------------------------------
/packages/formspree-react/src/stripe.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useElements } from '@stripe/react-stripe-js';
3 | import type { StripeElements } from '@stripe/stripe-js';
4 | import { createContext, useContext, type PropsWithChildren } from 'react';
5 |
6 | type StripeContextValue = {
7 | elements: StripeElements | null;
8 | };
9 |
10 | const StripeContext = createContext({ elements: null });
11 |
12 | export function StripeProvider(props: PropsWithChildren) {
13 | const { children } = props;
14 | const elements = useElements();
15 | return (
16 |
17 | {children}
18 |
19 | );
20 | }
21 |
22 | export function useStripeContext(): StripeContextValue {
23 | return useContext(StripeContext);
24 | }
25 |
--------------------------------------------------------------------------------
/packages/formspree-react/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { FieldValues, SubmissionData } from '@formspree/core';
2 | import type { FormEvent as ReactFormEvent } from 'react';
3 |
4 | /**
5 | * ExtraData values can be strings or functions that return a string, or a
6 | * promise that resolves to a string. Errors should be handled internally.
7 | * Functions can return undefined to skip this ExtraData value.
8 | */
9 | export type ExtraDataValue =
10 | | undefined
11 | | string
12 | | (() => string)
13 | | (() => Promise)
14 | | (() => undefined)
15 | | (() => Promise);
16 |
17 | export type ExtraData = {
18 | [key: string]: ExtraDataValue;
19 | };
20 |
21 | export type FormEvent = ReactFormEvent;
22 |
23 | export type SubmitHandler = (
24 | submission: FormEvent | SubmissionData
25 | ) => Promise;
26 |
--------------------------------------------------------------------------------
/packages/formspree-react/src/useForm.ts:
--------------------------------------------------------------------------------
1 | import {
2 | isSubmissionError,
3 | type Client,
4 | type FieldValues,
5 | type SubmissionError,
6 | type SubmissionSuccess,
7 | } from '@formspree/core';
8 | import { useState } from 'react';
9 | import type { ExtraData, SubmitHandler } from './types';
10 | import { useSubmit } from './useSubmit';
11 |
12 | type ResetFunction = () => void;
13 |
14 | export type TUseForm = [
15 | {
16 | errors: SubmissionError | null;
17 | result: SubmissionSuccess | null;
18 | submitting: boolean;
19 | succeeded: boolean;
20 | },
21 | SubmitHandler,
22 | ResetFunction
23 | ];
24 |
25 | export function useForm(
26 | formKey: string,
27 | args: {
28 | client?: Client;
29 | data?: ExtraData;
30 | endpoint?: string;
31 | } = {}
32 | ): TUseForm {
33 | const [errors, setErrors] = useState | null>(null);
34 | const [result, setResult] = useState(null);
35 | const [submitting, setSubmitting] = useState(false);
36 | const [succeeded, setSucceeded] = useState(false);
37 |
38 | if (!formKey) {
39 | throw new Error(
40 | 'You must provide a form key or hashid ' +
41 | '(e.g. useForm("myForm") or useForm("123xyz")'
42 | );
43 | }
44 |
45 | const submit = useSubmit(formKey, {
46 | client: args.client,
47 | extraData: args.data,
48 | origin: args.endpoint,
49 | });
50 |
51 | return [
52 | { errors, result, submitting, succeeded },
53 |
54 | async function handleSubmit(submissionData) {
55 | setSubmitting(true);
56 | const result = await submit(submissionData);
57 | setSubmitting(false);
58 | if (isSubmissionError(result)) {
59 | setErrors(result);
60 | setSucceeded(false);
61 | } else {
62 | setErrors(null);
63 | setResult(result);
64 | setSucceeded(true);
65 | }
66 | },
67 |
68 | function reset() {
69 | setErrors(null);
70 | setResult(null);
71 | setSubmitting(false);
72 | setSucceeded(false);
73 | },
74 | ];
75 | }
76 |
--------------------------------------------------------------------------------
/packages/formspree-react/src/useSubmit.ts:
--------------------------------------------------------------------------------
1 | import {
2 | appendExtraData,
3 | type Client,
4 | type FieldValues,
5 | type SubmissionData,
6 | type SubmissionResult,
7 | } from '@formspree/core';
8 | import { version } from '../package.json';
9 | import { useFormspree } from './context';
10 | import type { ExtraData, FormEvent, SubmitHandler } from './types';
11 | import { CardElement } from '@stripe/react-stripe-js';
12 | import { useStripeContext } from './stripe';
13 |
14 | const clientName = `@formspree/react@${version}`;
15 |
16 | type Options = {
17 | client?: Client;
18 | extraData?: ExtraData;
19 | // origin overrides the submission origin (default: "https://formspree.io")
20 | origin?: string;
21 | };
22 |
23 | export function useSubmit(
24 | formKey: string,
25 | options: Options = {}
26 | ): SubmitHandler> {
27 | const formspree = useFormspree();
28 | const { client = formspree.client, extraData, origin } = options;
29 | const { elements } = useStripeContext();
30 |
31 | const { stripe } = client;
32 |
33 | return async function handleSubmit(submission) {
34 | const data = isEvent(submission) ? getFormData(submission) : submission;
35 |
36 | // Append extra data from config
37 | if (typeof extraData === 'object') {
38 | for (const [prop, value] of Object.entries(extraData)) {
39 | let extraDataValue: string | undefined;
40 | if (typeof value === 'function') {
41 | extraDataValue = await value();
42 | } else {
43 | extraDataValue = value;
44 | }
45 | if (extraDataValue !== undefined) {
46 | appendExtraData(data, prop, extraDataValue);
47 | }
48 | }
49 | }
50 |
51 | const cardElement = elements?.getElement(CardElement);
52 | const createPaymentMethod =
53 | stripe && cardElement
54 | ? () =>
55 | stripe.createPaymentMethod({
56 | type: 'card',
57 | card: cardElement,
58 | billing_details: mapBillingDetailsPayload(data),
59 | })
60 | : undefined;
61 |
62 | return client.submitForm(formKey, data, {
63 | endpoint: origin,
64 | clientName,
65 | createPaymentMethod,
66 | });
67 | };
68 | }
69 |
70 | function isEvent(
71 | submission: FormEvent | SubmissionData
72 | ): submission is FormEvent {
73 | return (
74 | 'preventDefault' in submission &&
75 | typeof submission.preventDefault === 'function'
76 | );
77 | }
78 |
79 | function getFormData(event: FormEvent): FormData {
80 | event.preventDefault();
81 | const form = event.currentTarget;
82 | if (form.tagName != 'FORM') {
83 | throw new Error('submit was triggered for a non-form element');
84 | }
85 | return new FormData(form);
86 | }
87 |
88 | type StripeBillingDetailsPayload = {
89 | address: StripeAddressPayload;
90 | name?: string;
91 | email?: string;
92 | phone?: string;
93 | };
94 |
95 | function mapBillingDetailsPayload(
96 | data: SubmissionData
97 | ): StripeBillingDetailsPayload {
98 | const billing: StripeBillingDetailsPayload = {
99 | address: mapAddressPayload(data),
100 | };
101 |
102 | for (const key of ['name', 'email', 'phone'] as const) {
103 | const value = data instanceof FormData ? data.get(key) : data[key];
104 | if (value && typeof value === 'string') {
105 | billing[key] = value;
106 | }
107 | }
108 |
109 | return billing;
110 | }
111 |
112 | type StripeAddressPayload = Partial<{
113 | line1: string;
114 | line2: string;
115 | city: string;
116 | country: string;
117 | state: string;
118 | postal_code: string;
119 | }>;
120 |
121 | function mapAddressPayload(data: SubmissionData): StripeAddressPayload {
122 | const address: StripeAddressPayload = {};
123 |
124 | for (const [fromKey, toKey] of [
125 | ['address_line1', 'line1'],
126 | ['address_line2', 'line2'],
127 | ['address_city', 'city'],
128 | ['address_country', 'country'],
129 | ['address_state', 'state'],
130 | ['address_postal_code', 'postal_code'],
131 | ] as const) {
132 | const value = data instanceof FormData ? data.get(fromKey) : data[fromKey];
133 | if (value && typeof value === 'string') {
134 | address[toKey] = value;
135 | }
136 | }
137 |
138 | return address;
139 | }
140 |
--------------------------------------------------------------------------------
/packages/formspree-react/test/ValidationError.test.tsx:
--------------------------------------------------------------------------------
1 | import { SubmissionError } from '@formspree/core';
2 | import { act } from '@testing-library/react';
3 | import React from 'react';
4 | import ReactDOM from 'react-dom/client';
5 | import { ValidationError } from '../src';
6 |
7 | let container: HTMLElement;
8 |
9 | beforeEach(() => {
10 | container = document.createElement('div');
11 | document.body.appendChild(container);
12 | });
13 |
14 | afterEach(() => {
15 | document.body.removeChild(container);
16 | });
17 |
18 | it('renders a field error if one exists', () => {
19 | act(() => {
20 | ReactDOM.createRoot(container).render(
21 |
33 | );
34 | });
35 |
36 | expect(container).toMatchSnapshot();
37 | });
38 |
39 | it('renders field-less errors', () => {
40 | act(() => {
41 | ReactDOM.createRoot(container).render(
42 |
51 | );
52 | });
53 |
54 | expect(container).toMatchSnapshot();
55 | });
56 |
57 | it('does not render anything if the field does not have an error', () => {
58 | act(() => {
59 | ReactDOM.createRoot(container).render(
60 |
71 | );
72 | });
73 |
74 | expect(container).toMatchSnapshot();
75 | });
76 |
--------------------------------------------------------------------------------
/packages/formspree-react/test/__snapshots__/ValidationError.test.tsx.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`does not render anything if the field does not have an error 1`] = ``;
4 |
5 | exports[`renders a field error if one exists 1`] = `
6 |
7 |
10 | Email
11 | is required
12 |
13 |
14 | `;
15 |
16 | exports[`renders field-less errors 1`] = `
17 |
18 |
21 | Form is disabled
22 |
23 |
24 | `;
25 |
--------------------------------------------------------------------------------
/packages/formspree-react/test/context.test.tsx:
--------------------------------------------------------------------------------
1 | import { useStripe } from '@stripe/react-stripe-js';
2 | import type { Stripe } from '@stripe/stripe-js';
3 | import { loadStripe } from '@stripe/stripe-js/pure/index.js';
4 | import { render, renderHook, waitFor } from '@testing-library/react';
5 | import React from 'react';
6 | import { CardElement, FormspreeProvider, useFormspree } from '../src';
7 | import { createMockStripe } from './mockStripe';
8 |
9 | jest.mock('@stripe/stripe-js/pure/index.js');
10 |
11 | describe('FormspreeProvider', () => {
12 | describe('default', () => {
13 | it('creates a client available via context', () => {
14 | const { result } = renderHook(useFormspree, {
15 | wrapper: ({ children }) => (
16 | {children}
17 | ),
18 | });
19 | const { client } = result.current;
20 | expect(client).toBeTruthy();
21 | expect(client.project).toBeUndefined();
22 | expect(client.stripe).toBeUndefined();
23 | });
24 | });
25 |
26 | describe('with project', () => {
27 | it('creates a client available via context', () => {
28 | const project = 'test-project-id';
29 | const { result } = renderHook(useFormspree, {
30 | wrapper: ({ children }) => (
31 | {children}
32 | ),
33 | });
34 | const { client } = result.current;
35 | expect(client).toBeTruthy();
36 | expect(client.project).toBe(project);
37 | expect(client.stripe).toBeUndefined();
38 | });
39 | });
40 |
41 | describe('with Stripe', () => {
42 | let mockStripe: ReturnType;
43 |
44 | beforeEach(() => {
45 | mockStripe = createMockStripe();
46 | const mock = loadStripe as jest.MockedFn;
47 | mock.mockResolvedValue(mockStripe as unknown as Stripe);
48 | });
49 |
50 | let consoleError: jest.SpyInstance;
51 |
52 | beforeEach(() => {
53 | consoleError = jest.spyOn(console, 'error');
54 | });
55 |
56 | afterEach(() => {
57 | consoleError.mockRestore();
58 | });
59 |
60 | it('creates a client available via context', async () => {
61 | const { result } = renderHook(useFormspree, {
62 | wrapper: ({ children }) => (
63 |
64 | {children}
65 |
66 | ),
67 | });
68 |
69 | await waitFor(() => {
70 | const { client } = result.current;
71 | expect(client).toBeTruthy();
72 | expect(client.project).toBeUndefined();
73 | expect(client.stripe).toBe(mockStripe);
74 | });
75 | });
76 |
77 | it('passes Stripe client via Stripe Elements context', async () => {
78 | const { result } = renderHook(useStripe, {
79 | wrapper: ({ children }) => (
80 |
81 | {children}
82 |
83 | ),
84 | });
85 |
86 | expect(result.current).toBeNull();
87 | await waitFor(() => {
88 | expect(result.current).toBe(mockStripe);
89 | });
90 | });
91 |
92 | it('renders an app with CardElement without an error', () => {
93 | consoleError.mockImplementation(() => {}); // silent console.error
94 |
95 | expect(() => {
96 | render(
97 |
98 |
99 |
100 | );
101 | }).not.toThrow();
102 | });
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/packages/formspree-react/test/mockStripe.ts:
--------------------------------------------------------------------------------
1 | export function createMockStripe() {
2 | // This implements the method so it passes `isStripe` check in react-stripe-js.
3 | // It is lurky but Stripe doesn't provide a mock client.
4 | return {
5 | confirmCardPayment: jest.fn(),
6 | createPaymentMethod: jest.fn(),
7 | createToken: jest.fn(),
8 | elements: jest.fn(),
9 | handleCardAction: jest.fn(),
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/packages/formspree-react/test/useForm.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, waitFor } from '@testing-library/react';
2 | import userEvent from '@testing-library/user-event';
3 | import React from 'react';
4 | import { ValidationError, useForm, type ExtraData } from '../src';
5 |
6 | type TestFormProps = {
7 | extraData?: ExtraData;
8 | };
9 |
10 | function TestForm(props: TestFormProps) {
11 | const { extraData } = props;
12 | const [state, submit, reset] = useForm('test-form-id-42', {
13 | data: extraData,
14 | });
15 |
16 | if (state.submitting) {
17 | return Submitting…
;
18 | }
19 |
20 | if (state.succeeded) {
21 | return (
22 |
25 | );
26 | }
27 |
28 | return (
29 |
30 |
Form
31 |
49 |
50 | );
51 | }
52 |
53 | describe('useForm', () => {
54 | const fetch = jest.spyOn(window, 'fetch');
55 |
56 | beforeEach(() => {
57 | fetch.mockReset();
58 | });
59 |
60 | describe('given a successful submission', () => {
61 | it('renders the correct states', async () => {
62 | fetch.mockResolvedValue(
63 | new Response(JSON.stringify({ next: 'test-redirect-url' }))
64 | );
65 |
66 | render();
67 | userEvent.click(screen.getByText('Sign up'));
68 |
69 | // Right after clicked, it should show submitting.
70 | await screen.findByText('Submitting…');
71 |
72 | // Later, we should find the confirmation text.
73 | await screen.findByText('Thanks!');
74 |
75 | expect(fetch).toHaveBeenCalledTimes(1);
76 | expect(fetch).toHaveBeenLastCalledWith(
77 | 'https://formspree.io/f/test-form-id-42',
78 | expect.objectContaining({
79 | body: expect.any(FormData),
80 | headers: {
81 | Accept: 'application/json',
82 | 'Formspree-Client': expect.stringMatching(
83 | /^@formspree\/react@[\d.]+ @formspree\/core@[\d.]+$/
84 | ),
85 | 'Formspree-Session-Data': expect.any(String),
86 | },
87 | method: 'POST',
88 | mode: 'cors',
89 | })
90 | );
91 |
92 | const fetchPayload = fetch.mock.calls[0][1]?.body as FormData;
93 | expect(Array.from(fetchPayload)).toEqual([
94 | ['email', 'test@example.com'],
95 | // extra data is added
96 | ['secret', 'super-secret'],
97 | ]);
98 | });
99 | });
100 |
101 | describe('given a failed submission', () => {
102 | it('renders the correct states', async () => {
103 | fetch.mockResolvedValue(
104 | new Response(
105 | JSON.stringify({
106 | errors: [
107 | {
108 | message: '(test) forbidden',
109 | },
110 | {
111 | code: 'TYPE_EMAIL',
112 | field: 'email',
113 | message: '(test) should be an email',
114 | },
115 | ],
116 | })
117 | )
118 | );
119 |
120 | render();
121 | userEvent.click(screen.getByText('Sign up'));
122 |
123 | // Right after clicked, it should show submitting.
124 | await screen.findByText('Submitting…');
125 |
126 | // Later, it should revert back to form with errors.
127 | await waitFor(() => {
128 | screen.getByRole('heading', { name: 'Form' });
129 | screen.getByLabelText('Email');
130 | });
131 |
132 | expect(screen.getByTestId('form-errors').textContent).toBe(
133 | '(test) forbidden'
134 | );
135 | expect(screen.getByTestId('email-field-errors').textContent).toBe(
136 | '(test) should be an email'
137 | );
138 |
139 | expect(fetch).toHaveBeenCalledTimes(1);
140 | expect(fetch).toHaveBeenLastCalledWith(
141 | 'https://formspree.io/f/test-form-id-42',
142 | expect.objectContaining({
143 | body: expect.any(FormData),
144 | headers: {
145 | Accept: 'application/json',
146 | 'Formspree-Client': expect.stringMatching(
147 | /^@formspree\/react@[\d.]+ @formspree\/core@[\d.]+$/
148 | ),
149 | 'Formspree-Session-Data': expect.any(String),
150 | },
151 | method: 'POST',
152 | mode: 'cors',
153 | })
154 | );
155 |
156 | const fetchPayload = fetch.mock.calls[0][1]?.body as FormData;
157 | expect(Array.from(fetchPayload)).toEqual([
158 | ['email', 'test@example.com'],
159 | // extra data is added
160 | ['secret', 'super-secret'],
161 | ]);
162 | });
163 | });
164 |
165 | describe('when the reset function is called', () => {
166 | it('resets the form state', async () => {
167 | fetch.mockResolvedValue(
168 | new Response(JSON.stringify({ next: 'test-redirect-url' }))
169 | );
170 | render();
171 |
172 | await userEvent.click(screen.getByText('Sign up'));
173 | await screen.findByText('Thanks!');
174 |
175 | expect(fetch).toHaveBeenCalledTimes(1);
176 |
177 | await userEvent.click(screen.getByText('Reset'));
178 | await waitFor(() => {
179 | screen.getByRole('heading', { name: 'Form' });
180 | screen.getByLabelText('Email');
181 | });
182 | });
183 | });
184 |
185 | describe('when an empty form key is provided', () => {
186 | it('throws an error', async () => {
187 | function TestForm() {
188 | const [, submit] = useForm('');
189 | return (
190 |
194 | );
195 | }
196 |
197 | // avoid thrown error from `render` to be logged to console
198 | const spiedConsoleError = jest
199 | .spyOn(console, 'error')
200 | .mockImplementation();
201 |
202 | expect(() => render()).toThrowErrorMatchingInlineSnapshot(
203 | `"You must provide a form key or hashid (e.g. useForm("myForm") or useForm("123xyz")"`
204 | );
205 |
206 | spiedConsoleError.mockRestore();
207 | });
208 | });
209 | });
210 |
--------------------------------------------------------------------------------
/packages/formspree-react/test/useSubmit.test.tsx:
--------------------------------------------------------------------------------
1 | import { SubmissionError, type SubmissionResult } from '@formspree/core';
2 | import type {
3 | PaymentIntentResult,
4 | PaymentMethodResult,
5 | Stripe,
6 | } from '@stripe/stripe-js';
7 | import { loadStripe } from '@stripe/stripe-js/pure/index.js';
8 | import { render, screen, waitFor } from '@testing-library/react';
9 | import userEvent from '@testing-library/user-event';
10 | import React, { type FormEvent } from 'react';
11 | import {
12 | FormspreeProvider,
13 | useFormspree,
14 | useSubmit,
15 | type ExtraData,
16 | } from '../src';
17 | import { createMockStripe } from './mockStripe';
18 |
19 | jest.mock('@stripe/stripe-js/pure/index.js');
20 |
21 | describe('useSubmit', () => {
22 | const mockedFetch = jest.spyOn(window, 'fetch');
23 |
24 | beforeEach(() => {
25 | mockedFetch.mockReset();
26 | });
27 |
28 | describe('when submitting with form event', () => {
29 | function TestForm() {
30 | const handleSubmit = useSubmit<{ email: string }>('test-formspree-key');
31 | return (
32 |
36 | );
37 | }
38 |
39 | it('makes a POST request to Formspree with FormData as the body', async () => {
40 | render(
41 |
42 |
43 |
44 | );
45 |
46 | await userEvent.click(screen.getByRole('button'));
47 |
48 | expect(mockedFetch).toHaveBeenCalledTimes(1);
49 | expect(mockedFetch).toHaveBeenLastCalledWith(
50 | 'https://formspree.io/f/test-formspree-key',
51 | expect.objectContaining({
52 | body: expect.any(FormData),
53 | method: 'POST',
54 | mode: 'cors',
55 | })
56 | );
57 |
58 | const body = mockedFetch.mock.calls[0][1]?.body as FormData;
59 | expect(Array.from(body.entries())).toEqual([
60 | ['email', 'test@example.com'],
61 | ]);
62 | });
63 |
64 | // tests for append extra data is covered in FormData tests
65 | });
66 |
67 | describe('when submitting with FormData', () => {
68 | function TestForm({ extraData }: { extraData?: ExtraData }) {
69 | const handleSubmit = useSubmit<{ email: string }>('test-formspree-key', {
70 | extraData,
71 | });
72 | return (
73 |
83 | );
84 | }
85 |
86 | it('makes a POST request to Formspree with FormData as the body', async () => {
87 | render(
88 |
89 |
90 |
91 | );
92 |
93 | await userEvent.click(screen.getByRole('button'));
94 |
95 | expect(mockedFetch).toHaveBeenCalledTimes(1);
96 | expect(mockedFetch).toHaveBeenLastCalledWith(
97 | 'https://formspree.io/f/test-formspree-key',
98 | expect.objectContaining({
99 | body: expect.any(FormData),
100 | method: 'POST',
101 | mode: 'cors',
102 | })
103 | );
104 |
105 | const body = mockedFetch.mock.calls[0][1]?.body as FormData;
106 | expect(Array.from(body.entries())).toEqual([
107 | ['email', 'test@example.com'],
108 | ]);
109 | });
110 |
111 | it('appends extra data', async () => {
112 | const extraData = {
113 | justString: 'just-string',
114 | justUndefined: undefined,
115 | fnToString: () => 'fn-to-string',
116 | fnToUndefined: () => undefined,
117 | asyncFnToString: async () => 'async-fn-to-string',
118 | asyncFnToUndefined: async () => undefined,
119 | } satisfies ExtraData;
120 |
121 | render(
122 |
123 |
124 |
125 | );
126 |
127 | await userEvent.click(screen.getByRole('button'));
128 |
129 | expect(mockedFetch).toHaveBeenCalledTimes(1);
130 |
131 | const body = mockedFetch.mock.calls[0][1]?.body as FormData;
132 | expect(Array.from(body.entries())).toEqual([
133 | ['email', 'test@example.com'],
134 | ['justString', 'just-string'],
135 | ['fnToString', 'fn-to-string'],
136 | ['asyncFnToString', 'async-fn-to-string'],
137 | ]);
138 | });
139 | });
140 |
141 | describe('when submitting with plain object', () => {
142 | function TestForm({ extraData }: { extraData?: ExtraData }) {
143 | const handleSubmit = useSubmit<{ email: string }>('test-formspree-key', {
144 | extraData,
145 | });
146 | return (
147 |
155 | );
156 | }
157 |
158 | it('makes a POST request to Formspree with the JSON stringified object as the body', async () => {
159 | render(
160 |
161 |
162 |
163 | );
164 |
165 | await userEvent.click(screen.getByRole('button'));
166 |
167 | expect(mockedFetch).toHaveBeenCalledTimes(1);
168 | expect(mockedFetch).toHaveBeenLastCalledWith(
169 | 'https://formspree.io/f/test-formspree-key',
170 | expect.objectContaining({
171 | body: JSON.stringify({ email: 'test@example.com' }),
172 | method: 'POST',
173 | mode: 'cors',
174 | })
175 | );
176 | });
177 |
178 | it('appends extra data', async () => {
179 | const extraData = {
180 | justString: 'just-string',
181 | justUndefined: undefined,
182 | fnToString: () => 'fn-to-string',
183 | fnToUndefined: () => undefined,
184 | asyncFnToString: async () => 'async-fn-to-string',
185 | asyncFnToUndefined: async () => undefined,
186 | } satisfies ExtraData;
187 |
188 | render(
189 |
190 |
191 |
192 | );
193 |
194 | await userEvent.click(screen.getByRole('button'));
195 |
196 | expect(mockedFetch).toHaveBeenCalledTimes(1);
197 |
198 | const body = mockedFetch.mock.calls[0][1]?.body;
199 | expect(body).toEqual(
200 | JSON.stringify({
201 | email: 'test@example.com',
202 | justString: 'just-string',
203 | fnToString: 'fn-to-string',
204 | asyncFnToString: 'async-fn-to-string',
205 | })
206 | );
207 | });
208 | });
209 |
210 | describe('when submission fails', () => {
211 | type FormData = {
212 | email: string;
213 | };
214 |
215 | let result: SubmissionResult;
216 |
217 | function TestForm() {
218 | const submit = useSubmit('test-formspree-key');
219 |
220 | async function handleSubmit(
221 | event: FormEvent
222 | ): Promise {
223 | event.preventDefault();
224 | result = await submit({ email: 'test-email' });
225 | }
226 |
227 | return (
228 |
231 | );
232 | }
233 |
234 | it('returns an error result', async () => {
235 | mockedFetch.mockResolvedValue(
236 | new Response(
237 | JSON.stringify({
238 | errors: [
239 | {
240 | code: 'EMPTY',
241 | message: 'empty form',
242 | },
243 | {
244 | code: 'TYPE_EMAIL',
245 | field: 'email',
246 | message: 'must be an email',
247 | },
248 | ],
249 | })
250 | )
251 | );
252 |
253 | render(
254 |
255 |
256 |
257 | );
258 |
259 | await userEvent.click(screen.getByRole('button'));
260 |
261 | expect(result).toBeInstanceOf(SubmissionError);
262 | const errorResult = result as SubmissionError;
263 | expect(errorResult.getFormErrors()).toEqual([
264 | {
265 | code: 'EMPTY',
266 | message: 'empty form',
267 | },
268 | ]);
269 | expect(errorResult.getFieldErrors('email')).toEqual([
270 | {
271 | code: 'TYPE_EMAIL',
272 | message: 'must be an email',
273 | },
274 | ]);
275 | expect(errorResult.getAllFieldErrors()).toEqual([
276 | [
277 | 'email',
278 | [
279 | {
280 | code: 'TYPE_EMAIL',
281 | message: 'must be an email',
282 | },
283 | ],
284 | ],
285 | ]);
286 | });
287 | });
288 |
289 | describe('when submission succeeds', () => {
290 | type FormData = {
291 | email: string;
292 | };
293 |
294 | let result: SubmissionResult;
295 |
296 | function TestForm() {
297 | const submit = useSubmit('test-formspree-key');
298 |
299 | async function handleSubmit(
300 | event: FormEvent
301 | ): Promise {
302 | event.preventDefault();
303 | result = await submit({ email: 'test-email' });
304 | }
305 |
306 | return (
307 |
310 | );
311 | }
312 |
313 | it('returns a success result', async () => {
314 | mockedFetch.mockResolvedValue(
315 | new Response(JSON.stringify({ next: 'test-redirect-url' }))
316 | );
317 |
318 | render(
319 |
320 |
321 |
322 | );
323 |
324 | await userEvent.click(screen.getByRole('button'));
325 |
326 | expect(result).toEqual({
327 | kind: 'success',
328 | next: 'test-redirect-url',
329 | });
330 | });
331 | });
332 |
333 | describe('with Stripe (success)', () => {
334 | it('returns a success result', async () => {
335 | const mockLoadStripe = loadStripe as jest.MockedFn;
336 | const mockCardElement = { name: 'mocked-card-element' };
337 | const mockStripe = createMockStripe();
338 |
339 | mockStripe.createPaymentMethod.mockResolvedValue({
340 | paymentMethod: { id: 'test-payment-method-id' },
341 | } as PaymentMethodResult);
342 |
343 | mockStripe.elements.mockReturnValue({
344 | getElement() {
345 | return mockCardElement;
346 | },
347 | });
348 |
349 | mockStripe.handleCardAction.mockResolvedValue({
350 | paymentIntent: { id: 'test-payment-intent-id' },
351 | } as PaymentIntentResult);
352 |
353 | mockLoadStripe.mockResolvedValue(mockStripe as unknown as Stripe);
354 |
355 | let result: SubmissionResult;
356 |
357 | function TestForm() {
358 | const { client } = useFormspree();
359 | const handleSubmit = useSubmit('test-formspree-key');
360 | return (
361 |
380 | );
381 | }
382 |
383 | render(
384 |
385 |
386 |
387 | );
388 |
389 | await screen.findByText('Stripe is loaded');
390 |
391 | mockedFetch
392 | .mockResolvedValueOnce(
393 | new Response(
394 | JSON.stringify({
395 | resubmitKey: 'test-resubmit-key',
396 | stripe: {
397 | paymentIntentClientSecret: 'test-payment-intent-client-secret',
398 | },
399 | })
400 | )
401 | )
402 | .mockResolvedValueOnce(
403 | new Response(JSON.stringify({ next: 'test-redirect-url' }))
404 | );
405 |
406 | await userEvent.click(screen.getByRole('button'));
407 | await waitFor(() => {
408 | expect(result).toEqual({
409 | kind: 'success',
410 | next: 'test-redirect-url',
411 | });
412 | });
413 |
414 | expect(mockStripe.createPaymentMethod).toHaveBeenCalledTimes(1);
415 | expect(mockStripe.createPaymentMethod).toHaveBeenLastCalledWith({
416 | type: 'card',
417 | card: mockCardElement,
418 | billing_details: {
419 | address: {
420 | line1: 'test-addr-line1',
421 | line2: 'test-addr-line2',
422 | city: 'test-addr-city',
423 | country: 'test-addr-country',
424 | state: 'test-addr-state',
425 | postal_code: 'test-addr-postal_code',
426 | },
427 | email: 'test-email',
428 | name: 'John Doe',
429 | phone: 'test-phone-number',
430 | },
431 | });
432 | });
433 | });
434 | });
435 |
--------------------------------------------------------------------------------
/packages/formspree-react/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "@formspree/react",
4 | "extends": ["../../tsconfig.json"],
5 | "compilerOptions": {
6 | "target": "es5",
7 | "declaration": true,
8 | "declarationDir": "dist/types",
9 | "declarationMap": true,
10 | "jsx": "react",
11 | "resolveJsonModule": true
12 | },
13 | "include": ["src/**/*", "test/**/*"],
14 | "exclude": ["node_modules", "dist"]
15 | }
16 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "display": "Default",
4 | "compilerOptions": {
5 | "composite": false,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "inlineSources": false,
9 | "isolatedModules": true,
10 | "lib": ["ES2015", "DOM"],
11 | "module": "ESNext",
12 | "moduleResolution": "node",
13 | "noUnusedLocals": true,
14 | "noUnusedParameters": true,
15 | "preserveWatchOutput": true,
16 | "skipLibCheck": true,
17 | "strict": true,
18 | "target": "ES2015",
19 | "verbatimModuleSyntax": true
20 | },
21 | "exclude": ["node_modules"]
22 | }
23 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "globalDependencies": ["tsconfig.json"],
4 | "pipeline": {
5 | "build": {
6 | "outputs": ["build/**", "dist/**"],
7 | "dependsOn": ["^build"]
8 | },
9 | "clean": {
10 | "cache": false
11 | },
12 | "dev": {
13 | "dependsOn": ["^dev"],
14 | "cache": false
15 | },
16 | "lint": {},
17 | "test": {},
18 | "typecheck": {}
19 | }
20 | }
21 |
--------------------------------------------------------------------------------