mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f282f3d081 | ||
|
|
3e63e536e0 | ||
|
|
d5b5fd8dd9 | ||
|
|
3dafcb6d32 | ||
|
|
eea8f678bd | ||
|
|
4e5b03e62d | ||
|
|
28b6410dbb | ||
|
|
5a7492536a | ||
|
|
8b0347ab8a | ||
|
|
baf57883b0 | ||
|
|
d6775a5cda | ||
|
|
38366c5336 | ||
|
|
a4c571ffd3 | ||
|
|
35bda9d1de | ||
|
|
619bcb3a1f | ||
|
|
6cd3878700 | ||
|
|
e910a97d32 | ||
|
|
a0ba4ef9d3 | ||
|
|
b6e24c207a | ||
|
|
35ba2a1936 | ||
|
|
56e9c04659 | ||
|
|
5c378bc8ce | ||
|
|
2d63249f63 | ||
|
|
0a8eefbd63 | ||
|
|
b48dc100f4 | ||
|
|
f67365f6dd | ||
|
|
6ae4566baf | ||
|
|
0345247e9d | ||
|
|
dbdfdedf20 | ||
|
|
13cc2af59a | ||
|
|
c5450bd6ee | ||
|
|
de8173bc84 | ||
|
|
67638bb70a | ||
|
|
36bf9cc997 | ||
|
|
349fe3cb6c | ||
|
|
9c6d08e762 | ||
|
|
08278c8b0f | ||
|
|
3148d91fc9 | ||
|
|
64d84b69db | ||
|
|
db01eafb24 | ||
|
|
68ee24189c | ||
|
|
5be849b553 | ||
|
|
3311cc4ab6 | ||
|
|
e2bbb6e5a9 | ||
|
|
c674d58c69 | ||
|
|
f26e14df12 | ||
|
|
8a875922b3 | ||
|
|
a0ed5b7c0c | ||
|
|
246698ca02 | ||
|
|
ce02d2906a | ||
|
|
9c85190e7c | ||
|
|
cede0a83bf | ||
|
|
1a64baf83e | ||
|
|
f4f248860b | ||
|
|
b48018b2f8 | ||
|
|
844c590d7c | ||
|
|
2b41caaf1e | ||
|
|
f807ccf6cc | ||
|
|
6bf178fd85 | ||
|
|
c2d4a48fe3 | ||
|
|
973f999756 | ||
|
|
aa6c254872 | ||
|
|
5bb739f547 | ||
|
|
10e3db34a9 | ||
|
|
f1bede5816 | ||
|
|
419e12777a | ||
|
|
954adfa815 | ||
|
|
19b29188dd | ||
|
|
803007e0e2 | ||
|
|
28452e3c89 | ||
|
|
758474ec25 | ||
|
|
98fadfd476 | ||
|
|
37d284ebaa | ||
|
|
c49aad505b | ||
|
|
16650ff076 | ||
|
|
146a8f6608 | ||
|
|
75d7700d25 | ||
|
|
6bddc87021 | ||
|
|
e4129b23fa | ||
|
|
3190f6556a | ||
|
|
8391dceed1 | ||
|
|
602e8d3672 | ||
|
|
fcdeabb170 | ||
|
|
6cc4ecadef | ||
|
|
085a5aeed7 | ||
|
|
7ba0c66645 | ||
|
|
f3c9c4a99f | ||
|
|
aa648f9b2d | ||
|
|
54a217ee1e | ||
|
|
6da264f432 | ||
|
|
c1ca0ea568 | ||
|
|
24dc1f6a02 | ||
|
|
2c196db36d | ||
|
|
277bd014ae | ||
|
|
cb6b76c3e2 | ||
|
|
c20167342f | ||
|
|
3afe4a8a97 | ||
|
|
5539ec59b3 | ||
|
|
71dcf17e17 | ||
|
|
5e229057ff | ||
|
|
9f262cffde | ||
|
|
fa268630a5 | ||
|
|
8aa55bf8ae | ||
|
|
13462fccee | ||
|
|
0aa931287f | ||
|
|
4192461f5f |
8
.changeset/README.md
Normal file
8
.changeset/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Changesets
|
||||
|
||||
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
|
||||
with multi-package repos, or single-package repos to help you version and publish your code. You can
|
||||
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
|
||||
|
||||
We have a quick list of common questions to get you started engaging with this project in
|
||||
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
|
||||
11
.changeset/config.json
Normal file
11
.changeset/config.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": ["@formbricks/web"]
|
||||
}
|
||||
6
.dockerignore
Normal file
6
.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.turbo
|
||||
node_modules
|
||||
README.md
|
||||
.next
|
||||
95
.env.docker
Normal file
95
.env.docker
Normal file
@@ -0,0 +1,95 @@
|
||||
########################################################################
|
||||
# ------------ MANDATORY (CHANGE ACCORDING TO YOUR SETUP) ------------#
|
||||
########################################################################
|
||||
|
||||
|
||||
############
|
||||
# Basics #
|
||||
############
|
||||
|
||||
NEXTAUTH_SECRET=RANDOM_STRING
|
||||
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
<<<<<<< HEAD
|
||||
# This should always be localhost:3000 (or whatever port your app is running on)
|
||||
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
||||
|
||||
=======
|
||||
>>>>>>> 7ab8b81 (add basic react package)
|
||||
DATABASE_URL='postgresql://postgres:postgres@postgres:5432/snoopforms?schema=public'
|
||||
|
||||
################
|
||||
# Mail Setup #
|
||||
################
|
||||
|
||||
# Necessary if email verification and password reset are enabled.
|
||||
# See optional configurations below if you want to disable these features.
|
||||
|
||||
# MAIL_FROM=noreply@example.com
|
||||
# SMTP_HOST=localhost
|
||||
# SMTP_PORT=1025
|
||||
# SMTP_SECURE_ENABLED=0 # Enable for TLS (port 465)
|
||||
# SMTP_USER=smtpUser
|
||||
# SMTP_PASSWORD=smtpPassword
|
||||
|
||||
|
||||
########################################################################
|
||||
# ------------------------------ OPTIONAL -----------------------------#
|
||||
########################################################################
|
||||
|
||||
# Uncomment the variables you would like to use and customize the values.
|
||||
|
||||
#####################
|
||||
# Disable Features #
|
||||
#####################
|
||||
|
||||
# Email Verification. If you enable Email Verification you have to setup SMTP-Settings, too.
|
||||
<<<<<<< HEAD
|
||||
NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1
|
||||
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1
|
||||
|
||||
# Signup. Disable the ability for new users to create an account.
|
||||
# NEXT_PUBLIC_SIGNUP_DISABLED=1
|
||||
=======
|
||||
EMAIL_VERIFICATION_DISABLED=1
|
||||
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
PASSWORD_RESET_DISABLED=1
|
||||
>>>>>>> 7ab8b81 (add basic react package)
|
||||
|
||||
#######################
|
||||
# Additional Options #
|
||||
#######################
|
||||
|
||||
<<<<<<< HEAD
|
||||
# NEXT_PUBLIC_TERMS_URL=https://www.example.com/terms
|
||||
# NEXT_PUBLIC_PRIVACY_URL=https://www.example.com/privacy
|
||||
# NEXT_PUBLIC_IMPRINT_URL=https://www.example.com/imprint
|
||||
# NEXT_PUBLIC_PRIVACY_URL=https://www.example.com/enduserPrivacy
|
||||
=======
|
||||
# TERMS_URL=https://www.example.com/terms
|
||||
# PRIVACY_URL=https://www.example.com/privacy
|
||||
# PUBLIC_IMPRINT_URL=https://www.example.com/imprint
|
||||
# PUBLIC_PRIVACY_URL=https://www.example.com/enduserPrivacy
|
||||
>>>>>>> 7ab8b81 (add basic react package)
|
||||
|
||||
######################
|
||||
# Posthog Tracking #
|
||||
######################
|
||||
|
||||
# POSTHOG_API_HOST=https://app.posthog.com
|
||||
# POSTHOG_API_KEY=<YOUR POSTHOG API KEY>
|
||||
|
||||
###############
|
||||
# Telemetry #
|
||||
###############
|
||||
|
||||
# We also track anononymous usage telemetry on the server to improve snoopForms.
|
||||
# That way we can see how many people use snoopForms.
|
||||
# We can't identify you, or your users and only receive the number of submissions on your instance.
|
||||
# You help us a lot, if you leave this activated.
|
||||
# If you still want to opt-out, uncomment the next line.
|
||||
# TELEMETRY_DISABLED=1
|
||||
89
.env.example
89
.env.example
@@ -1,9 +1,84 @@
|
||||
# Modify this variables according to your setup and needs
|
||||
SECRET=RANDOM_STRING
|
||||
DATABASE_URL='postgresql://user@localhost:5432/snoopforms?schema=public'
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
ADMIN_EMAIL=user@example.com
|
||||
ADMIN_PASSWORD='admin123'
|
||||
########################################################################
|
||||
# ------------ MANDATORY (CHANGE ACCORDING TO YOUR SETUP) ------------#
|
||||
########################################################################
|
||||
|
||||
# For Docker Setup use this Database URL:
|
||||
|
||||
############
|
||||
# Basics #
|
||||
############
|
||||
|
||||
NEXTAUTH_SECRET=RANDOM_STRING
|
||||
|
||||
# Set this to your public-facing URL, e.g., https://example.com
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
<<<<<<< HEAD
|
||||
# This should always be localhost:3000 (or whatever port your app is running on)
|
||||
NEXTAUTH_URL_INTERNAL=http://localhost:3000
|
||||
|
||||
=======
|
||||
>>>>>>> 7ab8b81 (add basic react package)
|
||||
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/snoopforms?schema=public'
|
||||
# For Docker Compose Production Setup use this Database URL:
|
||||
# DATABASE_URL='postgresql://postgres:postgres@postgres:5432/snoopforms?schema=public'
|
||||
|
||||
################
|
||||
# Mail Setup #
|
||||
################
|
||||
|
||||
# Necessary if email verification and password reset are enabled.
|
||||
# See optional configurations below if you want to disable these features.
|
||||
|
||||
MAIL_FROM=noreply@example.com
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE_ENABLED=0 # Enable for TLS (port 465)
|
||||
SMTP_USER=smtpUser
|
||||
SMTP_PASSWORD=smtpPassword
|
||||
|
||||
|
||||
########################################################################
|
||||
# ------------------------------ OPTIONAL -----------------------------#
|
||||
########################################################################
|
||||
|
||||
# Uncomment the variables you would like to use and customize the values.
|
||||
|
||||
#####################
|
||||
# Disable Features #
|
||||
#####################
|
||||
|
||||
# Email Verification. If you enable Email Verification you have to setup SMTP-Settings, too.
|
||||
# NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1
|
||||
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
# NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1
|
||||
|
||||
# Signup. Disable the ability for new users to create an account.
|
||||
# NEXT_PUBLIC_SIGNUP_DISABLED=1
|
||||
|
||||
#######################
|
||||
# Additional Options #
|
||||
#######################
|
||||
|
||||
# NEXT_PUBLIC_TERMS_URL=https://www.example.com/terms
|
||||
# NEXT_PUBLIC_PRIVACY_URL=https://www.example.com/privacy
|
||||
# NEXT_PUBLIC_IMPRINT_URL=https://www.example.com/imprint
|
||||
# NEXT_PUBLIC_PRIVACY_URL=https://www.example.com/enduserPrivacy
|
||||
|
||||
######################
|
||||
# Posthog Tracking #
|
||||
######################
|
||||
|
||||
# POSTHOG_API_HOST=https://app.posthog.com
|
||||
# POSTHOG_API_KEY=<YOUR POSTHOG API KEY>
|
||||
|
||||
###############
|
||||
# Telemetry #
|
||||
###############
|
||||
|
||||
# We also track anononymous usage telemetry on the server to improve snoopForms.
|
||||
# That way we can see how many people use snoopForms.
|
||||
# We can't identify you, or your users and only receive the number of submissions on your instance.
|
||||
# You help us a lot, if you leave this activated.
|
||||
# If you still want to opt-out, uncomment the next line.
|
||||
# TELEMETRY_DISABLED=1
|
||||
|
||||
10
.eslintrc.js
Normal file
10
.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
// This tells ESLint to load the config from the package `eslint-config-formbricks`
|
||||
extends: ["formbricks"],
|
||||
settings: {
|
||||
next: {
|
||||
rootDir: ["apps/*/"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
44
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
44
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Something not working as expected? Let us look into it
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
Found a bug? Please fill out the sections below. 👍
|
||||
|
||||
### Issue Summary
|
||||
|
||||
<!--
|
||||
A summary of the issue. This needs to be a clear detailed-rich summary.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Steps to Reproduce
|
||||
|
||||
<!--
|
||||
1. (for example) Went to ...
|
||||
2. Clicked on...
|
||||
3. ...
|
||||
|
||||
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
## Environment
|
||||
|
||||
- [ ] snoopForms Cloud (app.snoopforms.com)
|
||||
- [ ] self-hosted snoopForms, version/commit: [please provide]
|
||||
|
||||
### Additional Context
|
||||
|
||||
<!--
|
||||
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
|
||||
- Node.js version
|
||||
- Anything else that you think could be an issue.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://discord.gg/3YFcABF2Ts
|
||||
about: Ask a general question about the project on our Discord server
|
||||
41
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
41
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest a feature or idea
|
||||
title: ""
|
||||
labels: feature
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
### Is your proposal related to a problem?
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what the problem is.
|
||||
For example, "I'm always frustrated when..."
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Describe the solution you'd like
|
||||
|
||||
<!--
|
||||
Provide a clear and concise description of what you want to happen.
|
||||
-->
|
||||
|
||||
(Describe your proposed solution here.)
|
||||
|
||||
### Describe alternatives you've considered
|
||||
|
||||
<!--
|
||||
Let us know about other solutions you've tried or researched.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
|
||||
### Additional context
|
||||
|
||||
<!--
|
||||
Is there anything else you can add about the proposal?
|
||||
You might want to link to related issues here, if you haven't already.
|
||||
-->
|
||||
|
||||
(Write your answer here.)
|
||||
36
.github/workflows/checks.yml
vendored
Normal file
36
.github/workflows/checks.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
name: Checks
|
||||
on: [push]
|
||||
jobs:
|
||||
build:
|
||||
name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }}
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
node: ["16.x"]
|
||||
os: [ubuntu-latest]
|
||||
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Use Node ${{ matrix.node }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
40
.github/workflows/release.yml
vendored
Normal file
40
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js 16.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 16.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2.2.2
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
# This expects you to have a script called release which does a build for your packages and calls changeset publish
|
||||
publish: pnpm release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -1,19 +1,21 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
.pnpm-store/
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
|
||||
# production
|
||||
/build
|
||||
# node
|
||||
dist/
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
@@ -23,7 +25,6 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
@@ -31,9 +32,7 @@ yarn-error.log*
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
!packages/database/.env
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
6
.npmrc
Normal file
6
.npmrc
Normal file
@@ -0,0 +1,6 @@
|
||||
auto-install-peers=true
|
||||
link-workspace-packages = true
|
||||
shamefully-hoist = true
|
||||
shared-workspace-shrinkwrap = true
|
||||
access = public
|
||||
enable-pre-post-scripts = true
|
||||
1
.prettierrc.js
Normal file
1
.prettierrc.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = require("./packages/prettier-config/prettier-preset");
|
||||
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-vscode-remote.remote-containers"
|
||||
]
|
||||
}
|
||||
28
.vscode/launch.json
vendored
Normal file
28
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "yarn dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "yarn dev",
|
||||
"serverReadyAction": {
|
||||
"pattern": "started server on .+, url: (https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
27
CONTRIBUTING.md
Normal file
27
CONTRIBUTING.md
Normal file
@@ -0,0 +1,27 @@
|
||||
There are many ways to contribute to snoopForms.
|
||||
|
||||
# Creating a PR
|
||||
|
||||
Please fork the repository, make your changes and create a new pull request if you want to make an update.
|
||||
|
||||
If you want to speak to us before doing lots of work, please join our [Discord server](https://snoopforms.com/discord) and tell us what you would like to work on - we're very responsive and friendly!
|
||||
|
||||
For QA of your Pull-Request, you can also get in touch with Matti on Discord. But we will also get to your PR without you taking additional action ;-)
|
||||
|
||||
# Issues
|
||||
|
||||
Spotted a bug? Has deployment gone wrong? Do you have user feedback? Raise an issue for the fastest response.
|
||||
|
||||
... or pick up and fix an issue if you want to do a Pull Request.
|
||||
|
||||
# Feature requests
|
||||
|
||||
Raise an issue for these and tag it as an Enhancement. We love every idea. Please give us as much context on the why as possible.
|
||||
|
||||
# Features
|
||||
|
||||
We are currently working on having a clear [Roadmap](https://github.com/orgs/formbricks/projects/1) for the next steps ahead.
|
||||
|
||||
But you can also pick a feature that is not already on the roadmap if you think it creates a positive impact for snoopForms.
|
||||
|
||||
If you are at all unsure, just raise it as an enhancement issue first and tell us that you like to work on it, and we'll very quickly respond.
|
||||
40
Dockerfile
40
Dockerfile
@@ -1,40 +0,0 @@
|
||||
# Install dependencies only when needed
|
||||
FROM node:16-alpine AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM node:16-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
RUN yarn prisma generate
|
||||
RUN yarn tsc prisma/seed.ts
|
||||
RUN yarn build && yarn install --production --ignore-scripts --prefer-offline
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM node:16-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nextjs -u 1001
|
||||
|
||||
COPY --from=builder /app/next.config.js ./
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
CMD ["yarn", "start"]
|
||||
111
README.md
111
README.md
@@ -1,39 +1,41 @@
|
||||
<p align="center">
|
||||
<a href="https://github.com/snoopForms/snoopforms">
|
||||
<a href="https://github.com/formbricks/snoopforms">
|
||||
<img src="https://user-images.githubusercontent.com/72809645/172191504-808da997-025b-4b1f-90c0-b8ef658af2dd.svg" alt="Logo" width="500">
|
||||
</a>
|
||||
<h3 align="center">snoopForms</h3>
|
||||
|
||||
<p align="center">
|
||||
The Open-Source Typeform Alternative
|
||||
Finally, good open-source forms!
|
||||
<br />
|
||||
<a href="https://snoopforms.com/">Website</a> | <a href="https://discord.gg/3YFcABF2Ts">Join Discord community</a>
|
||||
<a href="https://snoopforms.com/">Website & Hosted version</a> | <a href="https://snoopforms.com/discord">Join Discord community</a>
|
||||
</p>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/snoopForms/snoopforms/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a> <a href="https://discord.gg/3YFcABF2Ts"><img src="https://img.shields.io/badge/Discord-SnoopForms-%234A154B" alt="Join snoopForms Discord"></a>
|
||||
<a href="https://github.com/formbricks/snoopforms/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a> <a href="https://discord.gg/3YFcABF2Ts"><img src="https://img.shields.io/discord/979077669410979880?label=Discord&logo=discord&logoColor=%23fff" alt="Join snoopForms Discord"></a> <a href="https://github.com/formbricks/snoopforms/stargazers"><img src="https://img.shields.io/github/stars/snoopForms/snoopforms?logo=github" alt="Github Stars"></a>
|
||||
<a href="https://news.ycombinator.com/item?id=32303986"><img src="https://img.shields.io/badge/Hacker%20News-122-%23FF6600" alt="Hacker News"></a>
|
||||
<a href="https://www.producthunt.com/products/snoopforms"><img src="https://img.shields.io/badge/Product%20Hunt-%232%20Product%20of%20the%20Day-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
|
||||
</p>
|
||||
|
||||
<br/>
|
||||
|
||||
> :warning: **Note**: This repository is still in an early stage of development. We love the open source community and want to show what we are working on early. We will update this readme with more information once it is safe to use. Until then, feel free to share your thoughts, contact us, and contribute if you'd like.
|
||||
|
||||
<br/>
|
||||
|
||||
## About snoopForms
|
||||
|
||||
<img width="937" alt="screenshot-snoopForms" src="https://user-images.githubusercontent.com/675065/172094334-b5ca09d0-2058-42e3-9b05-75c79c098d06.svg">
|
||||
<img width="937" alt="snoopForms-architecture" src="https://user-images.githubusercontent.com/675065/182550268-09794c9e-1187-470e-b795-697ceb2a93b8.svg">
|
||||
|
||||
Spin up forms in minutes. Pipe your data exactly where you need it. Maximize your results with juicy analytics.
|
||||
|
||||
## What is snoopForms?
|
||||
|
||||
With snoopForms you can build complex multi-page forms in minutes using either our built-in No Code Builder or our [React library](https://github.com/formbricks/snoopforms-react). All form submissions are automatically sent to the snoopForms platform for processing and analysis. You can view the submission within the platform or you can easily configure pipelines to send your data to other systems, services or databases.
|
||||
|
||||
### Features
|
||||
|
||||
- Work with the React Lib or use our No Code Builder to build exactly the forms you need.
|
||||
- Pipe your data where you need it. Don’t wait for your form provider to finally build the integration you desperately need.
|
||||
- Since you can self-host Snoop Forms, it’s 100% compliant with all privacy regulations.
|
||||
- How users interact with your form can be as important as their input. Don’t miss anything with our best-in-class analytics.
|
||||
- We aim for the best possible developer experience. Use what you like, build on top what you need. Everything is possible.
|
||||
- React Lib & No Code Builder to build & integrate forms rapidly.
|
||||
- 100% compliant with all privacy regulations (self-hosted).
|
||||
- (next) Put your data to work with integrations.
|
||||
- (next) Juicy analytics out of the box.
|
||||
- (always) smooth Developer Experience comes first.
|
||||
|
||||
### Built With
|
||||
|
||||
@@ -43,81 +45,114 @@ Spin up forms in minutes. Pipe your data exactly where you need it. Maximize you
|
||||
- [TailwindCSS](https://tailwindcss.com/)
|
||||
- [Prisma](https://prisma.io/)
|
||||
|
||||
## Getting started
|
||||
## Cloud vs. self-hosted
|
||||
|
||||
We offer you a ready hosted and maintained version of snoopForms on [snoopforms.com](https://snoopforms.com). It is always up to date and offers a generous free plan. If you want to try snoopForms, or save yourself the hassle and stress of self-hosting, this is the place to start.
|
||||
|
||||
The version of snoopForms you'll find in this repository is the same version that runs in the cloud, and you can easily host it yourself on your servers. See the readme below for the deployment instructions.
|
||||
|
||||
(In the future we may develop additional features that aren't in the free Open-Source version)
|
||||
|
||||
## Get started with development
|
||||
|
||||
This repository is a monorepository using [Turborepo](https://turborepo.org/) and [pnpm](https://pnpm.io/). It contains the snoopForms [server application](https://github.com/formbricks/snoopforms/tree/main/apps/web), the [react library](https://github.com/formbricks/snoopforms/tree/main/packages/react) and other helper packages like database or UI library.
|
||||
|
||||
### How to run locally
|
||||
|
||||
To get the project running locally on your machine you need to have the following development tools installed:
|
||||
|
||||
- Node.JS (we recommend v16)
|
||||
- Yarn
|
||||
- PostgreSQL
|
||||
- [pnpm](https://pnpm.io/)
|
||||
- [Docker](https://www.docker.com/) (to run PostgreSQL / MailHog)
|
||||
|
||||
1. Clone the project:
|
||||
|
||||
```
|
||||
git clone https://github.com/snoopForms/snoopforms.git && cd snoopforms
|
||||
git clone https://github.com/formbricks/snoopforms.git
|
||||
```
|
||||
|
||||
2. Install Node.JS packages:
|
||||
and move into the directory
|
||||
|
||||
```
|
||||
yarn install
|
||||
cd snoopforms
|
||||
```
|
||||
|
||||
3. Create a `.env` file based on `.env.example` and change it according to your setup. Make sure the `DATABASE_URL` variable is set correctly according to your local database.
|
||||
2. Install Node.JS packages via pnpm. Don't have pnpm? Get it [here](https://pnpm.io/installation)
|
||||
|
||||
```
|
||||
pnpm install
|
||||
```
|
||||
|
||||
3. To make the process of installing a dev dependencies easier, we offer a [`docker-compose.yml`](https://docs.docker.com/compose/) with the following containers:
|
||||
|
||||
- a `postgres` container and environment variables preset to reach it,
|
||||
- a `mailhog` container that acts as a mock SMTP server and shows received mails in a web UI (forwarded to your host's `localhost:8025`)
|
||||
|
||||
```
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
4. Create a `.env` file based on `.env.example` and change it according to your setup. If you are using a cloud based database or another mail server, you will need to update the `DATABASE_URL` and SMTP settings in your `.env` accordingly.
|
||||
|
||||
```
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
4. Use the code editor of your choice to edit the .env file.
|
||||
|
||||
5. Make sure your PostgreSQL Database Server is running. Then let prisma set up the database for you:
|
||||
|
||||
```
|
||||
|
||||
yarn prisma migrate dev
|
||||
|
||||
pnpm dlx prisma migrate dev
|
||||
```
|
||||
|
||||
6. Start the development server:
|
||||
|
||||
```
|
||||
|
||||
yarn dev
|
||||
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
**You can now access the app on [https://localhost:3000](https://localhost:3000)**
|
||||
**You can now access the app on [https://localhost:3000](https://localhost:3000)**. You will be automatically redirected to the login. To use your local installation of snoopForms, create a new account.
|
||||
|
||||
## Deployment
|
||||
For viewing the confirmation email and other emails the system sends you, you can access mailhog at [https://localhost:8025](https://localhost:8025)
|
||||
|
||||
The easiest way to deploy snoopForms yourself on your own machine is using Docker. This requires Docker and the docker compose plugin on your system to work.
|
||||
## Deployment for Production Setup
|
||||
|
||||
The easiest way to deploy snoopForms on your own machine is using Docker. This requires Docker and the docker compose plugin on your system to work.
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```
|
||||
|
||||
git clone https://github.com/snoopForms/snoopforms.git && cd snoopforms
|
||||
git clone https://github.com/formbricks/snoopforms.git && cd snoopforms
|
||||
|
||||
```
|
||||
|
||||
Create a `.env` file based on `.env.example` and change it according to your setup.
|
||||
<<<<<<< HEAD
|
||||
Create a `.env` file based on `.env.docker` and change all fields according to your setup. This file comes with a basic setup and snoopForms works without making any changes to the file. To enable email sending functionality you need to configure the SMTP settings in the `.env` file. If you configured your email credentials, you can also comment the following lines to enable email verification (`# NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1`) and password reset (`# NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1`).
|
||||
=======
|
||||
Create a `.env` file based on `.env.docker` and change all fields according to your setup. This file comes with a basic setup and snoopForms works without making any changes to the file. To enable email sending functionality you need to configure the SMTP settings in the `.env` file. If you configured your email credentials, you can also comment the following lines to enable email verification (`# EMAIL_VERIFICATION_DISABLED=1`) and password reset (`# PASSWORD_RESET_DISABLED=1`).
|
||||
|
||||
> > > > > > > 7ab8b81 (add basic react package)
|
||||
|
||||
Copy the `.env.docker` file to `.env` and edit it with an editor of your choice if needed.
|
||||
|
||||
```
|
||||
|
||||
cp .env.example .env && nano .env
|
||||
cp .env.docker .env
|
||||
|
||||
```
|
||||
|
||||
Start the docker compose process to build and spin up the snoopForms container as well as the postgres database.
|
||||
Note: The environment variables are used at build time. When you change environment variables later, you need to rebuild the image with `docker compose build` for the changes to take effect.
|
||||
|
||||
```
|
||||
Finally start the docker compose process to build and spin up the snoopForms container as well as the PostgreSQL database.
|
||||
|
||||
```bash
|
||||
|
||||
docker compose up -d
|
||||
# (use docker-compose if you are on an older docker version)
|
||||
|
||||
```
|
||||
|
||||
You can now access the app on [https://localhost:3000](https://localhost:3000)
|
||||
You can now access the app on [https://localhost:3000](https://localhost:3000). You will be automatically redirected to the login. To use your local installation of snoopForms, create a new account.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
4
apps/web/.eslintrc.js
Normal file
4
apps/web/.eslintrc.js
Normal file
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["formbricks"],
|
||||
};
|
||||
40
apps/web/.gitignore
vendored
Normal file
40
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.idea/
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
8
apps/web/CHANGELOG.md
Normal file
8
apps/web/CHANGELOG.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# web
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [28b6410]
|
||||
- @snoopforms/react@0.3.6
|
||||
41
apps/web/Dockerfile
Normal file
41
apps/web/Dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
# Add lockfile and package.json's of isolated subworkspace
|
||||
FROM node:16-alpine AS installer
|
||||
RUN apk update
|
||||
RUN apk --no-cache add curl libc6-compat
|
||||
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
|
||||
WORKDIR /app
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY . .
|
||||
# Copy .env file because Docker don't follow symlinks
|
||||
COPY .env /app/apps/web/
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
# Build the project
|
||||
RUN pnpm dlx prisma generate
|
||||
RUN pnpm turbo run build --filter=web...
|
||||
|
||||
FROM node:16-alpine AS runner
|
||||
|
||||
RUN apk --no-cache add curl libc6-compat
|
||||
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
|
||||
|
||||
# Don't run production as root
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
USER nextjs
|
||||
|
||||
WORKDIR /home/nextjs
|
||||
|
||||
COPY --from=installer /app/apps/web/next.config.js .
|
||||
COPY --from=installer /app/apps/web/package.json .
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
COPY --from=installer --chown=nextjs:nodejs /app/packages/database/prisma ./packages/database/prisma
|
||||
|
||||
CMD pnpm dlx prisma migrate deploy && node apps/web/server.js
|
||||
@@ -1,11 +1,6 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import {
|
||||
DocumentAddIcon,
|
||||
PlusIcon,
|
||||
TerminalIcon,
|
||||
ViewGridAddIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { DotsHorizontalIcon, TrashIcon } from "@heroicons/react/solid";
|
||||
import { DocumentPlusIcon, PlusIcon, CommandLineIcon, SquaresPlusIcon } from "@heroicons/react/24/outline";
|
||||
import { EllipsisHorizontalIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { Fragment, useState } from "react";
|
||||
import { useForms } from "../lib/forms";
|
||||
@@ -37,7 +32,7 @@ export default function FormList() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full py-8">
|
||||
<div className="h-full px-6 py-8">
|
||||
{forms &&
|
||||
(forms.length === 0 ? (
|
||||
<div className="mt-5 text-center">
|
||||
@@ -47,18 +42,17 @@ export default function FormList() {
|
||||
hintText="Start by creating a form."
|
||||
buttonText="create form"
|
||||
borderStyles="border-4 border-dotted border-red"
|
||||
hasButton={true}
|
||||
>
|
||||
<DocumentAddIcon className="w-24 h-24 mx-auto text-ui-gray-medium stroke-thin" />
|
||||
hasButton={true}>
|
||||
<DocumentPlusIcon className="text-ui-gray-medium stroke-thin mx-auto h-24 w-24" />
|
||||
</EmptyPageFiller>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 place-content-stretch ">
|
||||
<ul className="grid grid-cols-2 place-content-stretch gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 ">
|
||||
<button onClick={() => newForm()}>
|
||||
<li className="col-span-1">
|
||||
<div className="overflow-hidden font-light text-white rounded-md shadow bg-snoopfade">
|
||||
<li className="col-span-1 h-56">
|
||||
<div className="bg-snoopfade flex h-full items-center justify-center overflow-hidden rounded-md font-light text-white shadow">
|
||||
<div className="px-4 py-8 sm:p-14">
|
||||
<PlusIcon className="mx-auto w-14 h-14 stroke-thin" />
|
||||
<PlusIcon className="stroke-thin mx-auto h-14 w-14" />
|
||||
create form
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,48 +61,40 @@ export default function FormList() {
|
||||
{forms
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map((form, formIdx) => (
|
||||
<li key={form.id} className="relative col-span-1 ">
|
||||
<div className="flex flex-col justify-between h-full bg-white rounded-md shadow">
|
||||
<div className="px-4 py-5 text-lg sm:p-6">
|
||||
{form.name}
|
||||
<li key={form.id} className="relative col-span-1 h-56">
|
||||
<div className="flex h-full flex-col justify-between rounded-md bg-white shadow">
|
||||
<div className="p-6">
|
||||
<p className="line-clamp-3 text-lg">{form.name}</p>
|
||||
</div>
|
||||
<Link href={`/forms/${form.id}`}>
|
||||
<a className="absolute w-full h-full" />
|
||||
<a className="absolute h-full w-full" />
|
||||
</Link>
|
||||
<div className="divide-y divide-ui-gray-light ">
|
||||
<div className="inline-flex px-2 py-1 mb-2 ml-4 text-sm rounded-sm bg-ui-gray-light text-ui-gray-dark">
|
||||
<div className="divide-ui-gray-light divide-y ">
|
||||
<div className="bg-ui-gray-light text-ui-gray-dark mb-2 ml-4 inline-flex rounded-sm px-2 py-1 text-sm">
|
||||
{form.formType == "NOCODE" ? (
|
||||
<div className="flex">
|
||||
<ViewGridAddIcon className="w-4 h-4 my-auto mr-1" />
|
||||
<SquaresPlusIcon className="my-auto mr-1 h-4 w-4" />
|
||||
No-Code
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex">
|
||||
<TerminalIcon className="w-4 h-4 my-auto mr-1" />
|
||||
<CommandLineIcon className="my-auto mr-1 h-4 w-4" />
|
||||
Code
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
|
||||
<p className="text-xs text-ui-gray-medium ">
|
||||
<p className="text-ui-gray-medium text-xs ">
|
||||
{form._count?.submissionSessions} responses
|
||||
</p>
|
||||
<Menu
|
||||
as="div"
|
||||
className="relative inline-block text-left"
|
||||
>
|
||||
<Menu as="div" className="relative z-10 inline-block text-left">
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div>
|
||||
<Menu.Button className="flex items-center p-2 -m-2 rounded-full text-red">
|
||||
<span className="sr-only">
|
||||
Open options
|
||||
</span>
|
||||
<DotsHorizontalIcon
|
||||
className="w-5 h-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Menu.Button className="text-red -m-2 flex items-center rounded-full p-2">
|
||||
<span className="sr-only">Open options</span>
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
|
||||
@@ -120,28 +106,23 @@ export default function FormList() {
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items
|
||||
static
|
||||
className="absolute left-0 w-56 px-1 mt-2 origin-top-right bg-white rounded-sm shadow-lg"
|
||||
>
|
||||
className="absolute left-0 mt-2 w-56 origin-top-right rounded-sm bg-white px-1 shadow-lg">
|
||||
<div className="py-1">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() =>
|
||||
deleteForm(form, formIdx)
|
||||
}
|
||||
onClick={() => deleteForm(form, formIdx)}
|
||||
className={classNames(
|
||||
active
|
||||
? "bg-ui-gray-light rounded-sm text-ui-black"
|
||||
? "bg-ui-gray-light text-ui-black rounded-sm"
|
||||
: "text-ui-gray-dark",
|
||||
"flex px-4 py-2 text-sm w-full"
|
||||
)}
|
||||
>
|
||||
"flex w-full px-4 py-2 text-sm"
|
||||
)}>
|
||||
<TrashIcon
|
||||
className="w-5 h-5 mr-3 text-ui-gray-dark"
|
||||
className="text-ui-gray-dark mr-3 h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>Delete Form</span>
|
||||
16
apps/web/components/Loading.tsx
Normal file
16
apps/web/components/Loading.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { TailSpin } from "react-loader-spinner";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="min-h-screen bg-white px-4 py-16 sm:px-6 sm:py-24 md:grid md:place-items-center lg:px-8">
|
||||
<div className="mx-auto max-w-max">
|
||||
<main>
|
||||
<div className="flex justify-center">
|
||||
<TailSpin color="#1f2937" height={30} width={30} />
|
||||
</div>
|
||||
<p className="text-ui-gray-dark mt-5 text-sm">Loading...</p>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,8 @@ export default function LoadingModal({ isLoading }) {
|
||||
static
|
||||
className="fixed inset-0 z-10 overflow-y-auto"
|
||||
open={isLoading}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
onClose={() => {}}>
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -20,16 +19,12 @@ export default function LoadingModal({ isLoading }) {
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Dialog.Overlay className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-20" />
|
||||
leaveTo="opacity-0">
|
||||
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-20 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
{/* This element is to trick the browser into centering the modal contents. */}
|
||||
<span
|
||||
className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
<Transition.Child
|
||||
@@ -39,9 +34,8 @@ export default function LoadingModal({ isLoading }) {
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<div className="inline-flex items-center justify-center px-4 py-20 pb-4 overflow-hidden text-left align-bottom transition-all transform rounded-lg sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<div className="inline-flex transform items-center justify-center overflow-hidden rounded-lg px-4 py-20 pb-4 text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 sm:align-middle">
|
||||
<TailSpin color="#000" height={50} width={50} />
|
||||
</div>
|
||||
</Transition.Child>
|
||||
11
apps/web/components/MessagePage.tsx
Normal file
11
apps/web/components/MessagePage.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function MessagePage({ text }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white px-4 py-16 sm:px-6 sm:py-24 md:grid md:place-items-center lg:px-8">
|
||||
<div className="mx-auto max-w-max">
|
||||
<main>
|
||||
<div className="text-ui-gray-dark flex justify-center text-sm">{text}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
apps/web/components/Modal.tsx
Normal file
49
apps/web/components/Modal.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { Fragment } from "react";
|
||||
|
||||
export default function Modal({ open, setOpen, children }) {
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={setOpen}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-xl sm:p-6 lg:max-w-3xl">
|
||||
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
onClick={() => setOpen(false)}>
|
||||
<span className="sr-only">Close</span>
|
||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-col sm:flex sm:items-start">{children}</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const StandardButton: React.FC<Props> = ({
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
`inline-flex items-center rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500`,
|
||||
`inline-flex items-center rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2`,
|
||||
disabled ? "disabled:opacity-50" : "",
|
||||
fullwidth ? " w-full justify-center " : "",
|
||||
small ? "px-2.5 py-1.5 text-xs" : "px-5 py-3 text-sm",
|
||||
@@ -35,8 +35,7 @@ const StandardButton: React.FC<Props> = ({
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
{...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
138
apps/web/components/builder/Builder.tsx
Normal file
138
apps/web/components/builder/Builder.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import EditorJS from "@editorjs/editorjs";
|
||||
import {
|
||||
CogIcon,
|
||||
DocumentPlusIcon,
|
||||
EyeIcon,
|
||||
PaperAirplaneIcon,
|
||||
ShareIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/router";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { useForm } from "../../lib/forms";
|
||||
import { persistNoCodeForm, useNoCodeForm } from "../../lib/noCodeForm";
|
||||
import LimitedWidth from "../layout/LimitedWidth";
|
||||
import SecondNavBar from "../layout/SecondNavBar";
|
||||
import Loading from "../Loading";
|
||||
import LoadingModal from "../LoadingModal";
|
||||
import ShareModal from "./ShareModal";
|
||||
import SettingsModal from "./SettingsModal";
|
||||
let Editor = dynamic(() => import("../editorjs/Editor"), {
|
||||
ssr: false,
|
||||
});
|
||||
/* import Editor from "../editorjs/Editor"; */
|
||||
|
||||
export default function Builder({ formId }) {
|
||||
const router = useRouter();
|
||||
const editorRef = useRef<EditorJS | null>();
|
||||
const { isLoadingForm } = useForm(formId);
|
||||
const { noCodeForm, isLoadingNoCodeForm, mutateNoCodeForm } = useNoCodeForm(formId);
|
||||
const [openShareModal, setOpenShareModal] = useState(false);
|
||||
const [openSettingsModal, setOpenSettingsModal] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const addPage = () => {
|
||||
editorRef.current.blocks.insert("pageTransition", {
|
||||
submitLabel: "Submit",
|
||||
});
|
||||
const block = editorRef.current.blocks.insert("paragraph");
|
||||
editorRef.current.caret.setToBlock(editorRef.current.blocks.getBlockIndex(block.id));
|
||||
};
|
||||
|
||||
const initAction = async (editor: EditorJS) => {
|
||||
editor.blocks.insert("header", {
|
||||
text: noCodeForm.form.name,
|
||||
});
|
||||
const focusBlock = editor.blocks.insert("textQuestion");
|
||||
editor.blocks.insert("pageTransition", {
|
||||
submitLabel: "Submit",
|
||||
});
|
||||
editor.blocks.insert("header", {
|
||||
text: "Thank you",
|
||||
});
|
||||
editor.blocks.insert("paragraph", {
|
||||
text: "Thanks a lot for your time and insights 🙏",
|
||||
});
|
||||
editor.blocks.delete(0); // remove defaultBlock
|
||||
editor.caret.setToBlock(editorRef.current.blocks.getBlockIndex(focusBlock.id));
|
||||
};
|
||||
|
||||
const publishChanges = async () => {
|
||||
setLoading(true);
|
||||
setTimeout(async () => {
|
||||
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
|
||||
const firstPublish = newNoCodeForm.published ? false : true;
|
||||
newNoCodeForm.blocks = newNoCodeForm.blocksDraft;
|
||||
newNoCodeForm.published = true;
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
setLoading(false);
|
||||
toast(firstPublish ? "Your form is now published 🎉" : "Your changes are now published 🎉");
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const noCodeSecondNavigation = [
|
||||
{
|
||||
id: "addPage",
|
||||
onClick: () => addPage(),
|
||||
Icon: DocumentPlusIcon,
|
||||
//Icon: PlusIcon
|
||||
label: "Page",
|
||||
},
|
||||
{
|
||||
id: "preview",
|
||||
onClick: () => {
|
||||
router.push(`/forms/${formId}/preview`);
|
||||
},
|
||||
Icon: EyeIcon,
|
||||
label: "Preview",
|
||||
},
|
||||
{
|
||||
id: "publish",
|
||||
onClick: () => publishChanges(),
|
||||
Icon: PaperAirplaneIcon,
|
||||
label: "Publish",
|
||||
},
|
||||
{
|
||||
id: "share",
|
||||
onClick: () => setOpenShareModal(true),
|
||||
Icon: ShareIcon,
|
||||
label: "Share",
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
onClick: () => setOpenSettingsModal(true),
|
||||
Icon: CogIcon,
|
||||
label: "Settings",
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoadingNoCodeForm || isLoadingForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SecondNavBar navItems={noCodeSecondNavigation} />
|
||||
<div className="mb-20 h-full w-full overflow-auto bg-white">
|
||||
<div className="flex w-full justify-center pt-10 pb-56">
|
||||
<LimitedWidth>
|
||||
{Editor && (
|
||||
<Editor
|
||||
id="editor"
|
||||
formId={formId}
|
||||
editorRef={editorRef}
|
||||
autofocus={true}
|
||||
initAction={initAction}
|
||||
/>
|
||||
)}
|
||||
</LimitedWidth>
|
||||
</div>
|
||||
</div>
|
||||
<ShareModal open={openShareModal} setOpen={setOpenShareModal} formId={formId} />
|
||||
<SettingsModal open={openSettingsModal} setOpen={setOpenSettingsModal} formId={formId} />
|
||||
<LoadingModal isLoading={loading} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { TrashIcon } from "@heroicons/react/solid";
|
||||
import { TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { MdWavingHand } from "react-icons/md";
|
||||
import { classNames } from "../../lib/utils";
|
||||
|
||||
export default function PageToolbar({
|
||||
page,
|
||||
pageIdx,
|
||||
deletePageAction,
|
||||
setPageType,
|
||||
}) {
|
||||
export default function PageToolbar({ page, pageIdx, deletePageAction, setPageType }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
@@ -29,14 +24,13 @@ export default function PageToolbar({
|
||||
page.type === "thankyou"
|
||||
? "bg-red-400 text-white hover:bg-red-500"
|
||||
: "bg-white text-gray-400 hover:bg-gray-50",
|
||||
"has-tooltip relative inline-flex items-center px-4 py-2 text-sm font-medium border border-gray-300 rounded-l-md focus:z-10 focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
|
||||
)}
|
||||
>
|
||||
"has-tooltip relative inline-flex items-center rounded-l-md border border-gray-300 px-4 py-2 text-sm font-medium focus:z-10 focus:border-red-500 focus:outline-none focus:ring-1 focus:ring-red-500"
|
||||
)}>
|
||||
<span className="sr-only">Thank You Page</span>
|
||||
<span className="w-32 p-1 -mt-16 -ml-10 text-xs text-white bg-gray-600 rounded shadow-lg tooltip">
|
||||
<span className="tooltip -mt-16 -ml-10 w-32 rounded bg-gray-600 p-1 text-xs text-white shadow-lg">
|
||||
Is Thank You Page
|
||||
</span>
|
||||
<MdWavingHand className="w-4 h-4" aria-hidden="true" />
|
||||
<MdWavingHand className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -45,13 +39,12 @@ export default function PageToolbar({
|
||||
deletePageAction(pageIdx);
|
||||
}
|
||||
}}
|
||||
className="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-400 bg-white border border-gray-300 has-tooltip rounded-r-md hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
|
||||
>
|
||||
className="has-tooltip relative inline-flex items-center rounded-r-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-400 hover:bg-gray-50 focus:z-10 focus:border-red-500 focus:outline-none focus:ring-1 focus:ring-red-500">
|
||||
<span className="sr-only">Delete</span>
|
||||
<span className="w-24 p-1 -mt-16 -ml-8 text-xs text-white bg-gray-600 rounded shadow-lg tooltip">
|
||||
<span className="tooltip -mt-16 -ml-8 w-24 rounded bg-gray-600 p-1 text-xs text-white shadow-lg">
|
||||
Delete Page
|
||||
</span>
|
||||
<TrashIcon className="w-4 h-4" aria-hidden="true" />
|
||||
<TrashIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
115
apps/web/components/builder/SettingsModal.tsx
Normal file
115
apps/web/components/builder/SettingsModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { Dialog, Switch, Transition } from "@headlessui/react";
|
||||
import { Fragment, useState } from "react";
|
||||
import { TailSpin } from "react-loader-spinner";
|
||||
import { persistNoCodeForm, useNoCodeForm } from "../../lib/noCodeForm";
|
||||
import Loading from "../Loading";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { toast } from "react-toastify";
|
||||
import { classNames } from "../../lib/utils";
|
||||
|
||||
export default function SettingsModal({ open, setOpen, formId }) {
|
||||
const { noCodeForm, isLoadingNoCodeForm, mutateNoCodeForm } = useNoCodeForm(formId);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const toggleClose = async () => {
|
||||
setLoading(true);
|
||||
setTimeout(async () => {
|
||||
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
|
||||
newNoCodeForm.closed = !noCodeForm.closed;
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
setLoading(false);
|
||||
toast(
|
||||
newNoCodeForm.closed
|
||||
? "Your form is now closed for submissions "
|
||||
: "Your form is now open for submissions 🎉"
|
||||
);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
if (isLoadingNoCodeForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition.Root show={open} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={setOpen}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl sm:p-6">
|
||||
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
onClick={() => setOpen(false)}>
|
||||
<span className="sr-only">Close</span>
|
||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-medium leading-6 text-gray-900">Settings</h1>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Access</h3>
|
||||
<div className="mt-2 w-full text-sm text-gray-500">
|
||||
<Switch.Group as="div" className="flex w-full items-center justify-between">
|
||||
<span className="flex flex-grow flex-col">
|
||||
<Switch.Label as="span" className="text-sm font-medium text-gray-900" passive={true}>
|
||||
Close form for new submissions?
|
||||
</Switch.Label>
|
||||
<Switch.Description as="span" className="text-sm text-gray-500">
|
||||
Your form is currently{" "}
|
||||
<span className="font-bold">{noCodeForm.closed ? "closed" : "open"}</span> for
|
||||
submissions.
|
||||
</Switch.Description>
|
||||
</span>
|
||||
{loading ? (
|
||||
<TailSpin color="#1f2937" height={30} width={30} />
|
||||
) : (
|
||||
<Switch
|
||||
checked={noCodeForm.closed}
|
||||
onChange={() => toggleClose()}
|
||||
className={classNames(
|
||||
noCodeForm.closed ? "bg-red-600" : "bg-gray-200",
|
||||
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
noCodeForm.closed ? "translate-x-5" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
)}
|
||||
</Switch.Group>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { InformationCircleIcon, XIcon } from "@heroicons/react/outline";
|
||||
import { InformationCircleIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { toast } from "react-toastify";
|
||||
import { Fragment } from "react";
|
||||
import { useNoCodeForm } from "../../lib/noCodeForm";
|
||||
import Loading from "../Loading";
|
||||
@@ -28,13 +29,12 @@ export default function ShareModal({ open, setOpen, formId }) {
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" />
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -42,47 +42,36 @@ export default function ShareModal({ open, setOpen, formId }) {
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative px-4 pt-5 pb-4 overflow-hidden text-left transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:max-w-4xl sm:w-full sm:p-6">
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl sm:p-6">
|
||||
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 bg-white rounded-md hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
onClick={() => setOpen(false)}>
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon className="w-6 h-6" aria-hidden="true" />
|
||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{!noCodeForm.published ? (
|
||||
<div className="p-4 border border-gray-700 rounded-md bg-ui-gray-light">
|
||||
<div className="bg-ui-gray-light rounded-md border border-gray-700 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon
|
||||
className="w-5 h-5 text-blue-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<InformationCircleIcon className="h-5 w-5 text-blue-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="flex-1 ml-3 md:flex md:justify-between">
|
||||
<div className="ml-3 flex-1 md:flex md:justify-between">
|
||||
<p className="text-sm text-gray-700">
|
||||
You haven't published this form yet. Please
|
||||
publish this form to share it with others and get the
|
||||
first submissions.
|
||||
You haven't published this form yet. Please publish this form to share it with
|
||||
others and get the first submissions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">
|
||||
Share your form
|
||||
</h3>
|
||||
<div className="max-w-xl mt-2 text-sm text-gray-500">
|
||||
<p>
|
||||
Let your participants fill out your form by accessing it
|
||||
via the public link.
|
||||
</p>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Share your form</h3>
|
||||
<div className="mt-2 max-w-xl text-sm text-gray-500">
|
||||
<p>Let your participants fill out your form by accessing it via the public link.</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:flex sm:items-center">
|
||||
<div className="w-full sm:max-w-xs">
|
||||
@@ -93,7 +82,7 @@ export default function ShareModal({ open, setOpen, formId }) {
|
||||
id="surveyLink"
|
||||
type="text"
|
||||
placeholder="Enter your email"
|
||||
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 sm:text-sm"
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
|
||||
value={getPublicFormUrl()}
|
||||
disabled
|
||||
/>
|
||||
@@ -101,9 +90,9 @@ export default function ShareModal({ open, setOpen, formId }) {
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(getPublicFormUrl());
|
||||
toast("Link copied to clipboard 🙌");
|
||||
}}
|
||||
className="inline-flex items-center justify-center w-full px-4 py-2 mt-3 font-medium text-white bg-gray-800 border border-transparent rounded-md shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
className="mt-3 inline-flex w-full items-center justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2 font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
124
apps/web/components/editorjs/Editor.tsx
Normal file
124
apps/web/components/editorjs/Editor.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import EditorJS from "@editorjs/editorjs";
|
||||
import Header from "@editorjs/header";
|
||||
import Paragraph from "@editorjs/paragraph";
|
||||
import DragDrop from "editorjs-drag-drop";
|
||||
import Undo from "editorjs-undo";
|
||||
import { Fragment, useCallback, useEffect } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
import { persistNoCodeForm, useNoCodeForm } from "../../lib/noCodeForm";
|
||||
import Loading from "../Loading";
|
||||
import EmailQuestion from "./tools/EmailQuestion";
|
||||
import PageTransition from "./tools/PageTransition";
|
||||
import MultipleChoiceQuestion from "./tools/MultipleChoiceQuestion";
|
||||
import TextQuestion from "./tools/TextQuestion";
|
||||
import WebsiteQuestion from "./tools/WebsiteQuestion";
|
||||
import PhoneQuestion from "./tools/PhoneQuestion";
|
||||
import NumberQuestion from "./tools/NumberQuestion";
|
||||
import TextareaQuestion from "./tools/TextareaQuestion";
|
||||
|
||||
interface EditorProps {
|
||||
id: string;
|
||||
autofocus: boolean;
|
||||
editorRef: { current: EditorJS | null };
|
||||
formId: string;
|
||||
initAction: (editor: EditorJS) => void;
|
||||
}
|
||||
|
||||
const Editor = ({ id, autofocus = false, editorRef, formId, initAction }: EditorProps) => {
|
||||
const { noCodeForm, isLoadingNoCodeForm, mutateNoCodeForm } = useNoCodeForm(formId);
|
||||
|
||||
const keyPressListener = useCallback((e) => {
|
||||
if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
toast("snoopForms autosaves your work ✌️");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", keyPressListener);
|
||||
// Remove event listeners on cleanup
|
||||
return () => {
|
||||
window.removeEventListener("keydown", keyPressListener);
|
||||
};
|
||||
}, [keyPressListener]);
|
||||
|
||||
// This will run only once
|
||||
useEffect(() => {
|
||||
if (!isLoadingNoCodeForm) {
|
||||
if (!editorRef.current) {
|
||||
initEditor();
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
destroyEditor();
|
||||
};
|
||||
async function destroyEditor() {
|
||||
await editorRef.current.isReady;
|
||||
editorRef.current.destroy();
|
||||
editorRef.current = null;
|
||||
}
|
||||
}, [isLoadingNoCodeForm]);
|
||||
|
||||
const initEditor = () => {
|
||||
const editor = new EditorJS({
|
||||
minHeight: 0,
|
||||
holder: id,
|
||||
data: { blocks: noCodeForm.blocksDraft },
|
||||
onReady: () => {
|
||||
editorRef.current = editor;
|
||||
new DragDrop(editor);
|
||||
new Undo({ editor });
|
||||
if (editor.blocks.getBlocksCount() === 1) {
|
||||
initAction(editor);
|
||||
}
|
||||
},
|
||||
onChange: async () => {
|
||||
let content = await editor.saver.save();
|
||||
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
|
||||
newNoCodeForm.blocksDraft = content.blocks;
|
||||
await persistNoCodeForm(newNoCodeForm);
|
||||
mutateNoCodeForm(newNoCodeForm);
|
||||
},
|
||||
autofocus: autofocus,
|
||||
defaultBlock: "paragraph",
|
||||
tools: {
|
||||
textQuestion: TextQuestion,
|
||||
textareaQuestion: TextareaQuestion,
|
||||
emailQuestion: EmailQuestion,
|
||||
multipleChoiceQuestion: MultipleChoiceQuestion,
|
||||
numberQuestion: NumberQuestion,
|
||||
phoneQuestion: PhoneQuestion,
|
||||
websiteQuestion: WebsiteQuestion,
|
||||
pageTransition: PageTransition,
|
||||
paragraph: {
|
||||
class: Paragraph,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
placeholder: "Start with your content or hit tab-key to insert block",
|
||||
},
|
||||
},
|
||||
header: {
|
||||
class: Header,
|
||||
config: {
|
||||
placeholder: "Enter a header",
|
||||
levels: [1, 2, 3],
|
||||
defaultLevel: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingNoCodeForm) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div id={id} />
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Editor;
|
||||
102
apps/web/components/editorjs/tools/EmailQuestion.tsx
Normal file
102
apps/web/components/editorjs/tools/EmailQuestion.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import EmailQuestionComponent from "./EmailQuestionComponent";
|
||||
|
||||
export interface EmailQuestionData extends BlockToolData {
|
||||
label: string;
|
||||
help: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export default class EmailQuestion implements BlockTool {
|
||||
settings: { name: string; icon: string }[];
|
||||
api: API;
|
||||
data: EmailQuestionData;
|
||||
nodes: { holder: HTMLElement };
|
||||
config: ToolConfig;
|
||||
|
||||
static get toolbox(): { icon: string; title?: string } {
|
||||
return {
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M14.243 5.757a6 6 0 10-.986 9.284 1 1 0 111.087 1.678A8 8 0 1118 10a3 3 0 01-4.8 2.401A4 4 0 1114 10a1 1 0 102 0c0-1.537-.586-3.07-1.757-4.243zM12 10a2 2 0 10-4 0 2 2 0 004 0z" clip-rule="evenodd" />
|
||||
</svg>`,
|
||||
title: "Email Question",
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ api, config, data }: { api: API; config?: ToolConfig; data?: EmailQuestionData }) {
|
||||
this.api = api;
|
||||
this.config = config;
|
||||
|
||||
this.settings = [
|
||||
{
|
||||
name: "required",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
|
||||
},
|
||||
];
|
||||
|
||||
this.data = {
|
||||
label: data.label || "",
|
||||
help: data.help || "",
|
||||
placeholder: data.placeholder || "your email",
|
||||
required: data.required || false,
|
||||
};
|
||||
|
||||
this.nodes = {
|
||||
holder: null,
|
||||
};
|
||||
}
|
||||
|
||||
renderSettings(): HTMLElement {
|
||||
const wrapper = document.createElement("div");
|
||||
|
||||
this.settings.forEach((tune) => {
|
||||
let button = document.createElement("div");
|
||||
|
||||
button.classList.add("cdx-settings-button");
|
||||
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
|
||||
button.innerHTML = tune.icon;
|
||||
wrapper.appendChild(button);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this._toggleTune(tune.name);
|
||||
button.classList.toggle("cdx-settings-button--active");
|
||||
});
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Click on the Settings Button
|
||||
* @param {string} tune — tune name from this.settings
|
||||
*/
|
||||
_toggleTune(tune: string) {
|
||||
if (tune === "required") {
|
||||
this.data.required = !this.data.required;
|
||||
}
|
||||
}
|
||||
|
||||
render(): HTMLElement {
|
||||
const rootNode = document.createElement("div");
|
||||
|
||||
this.nodes.holder = rootNode;
|
||||
|
||||
const onDataChange = (newData: EmailQuestionData) => {
|
||||
this.data = {
|
||||
...newData,
|
||||
};
|
||||
};
|
||||
|
||||
ReactDOM.render(<EmailQuestionComponent onDataChange={onDataChange} data={this.data} />, rootNode);
|
||||
|
||||
return this.nodes.holder;
|
||||
}
|
||||
|
||||
save() {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { EnvelopeIcon } from "@heroicons/react/24/solid";
|
||||
import { default as React } from "react";
|
||||
import { type EmailQuestionData } from "./EmailQuestion";
|
||||
|
||||
const DEFAULT_INITIAL_DATA = () => {
|
||||
return {
|
||||
label: "",
|
||||
placeholder: "",
|
||||
help: "",
|
||||
required: false,
|
||||
};
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: EmailQuestionData;
|
||||
onDataChange: (newData: EmailQuestionData) => void;
|
||||
};
|
||||
|
||||
const EmailQuestionComponent = (props: Props) => {
|
||||
const [data, setData] = React.useState(props.data ? props.data : DEFAULT_INITIAL_DATA);
|
||||
|
||||
const updateData = (newData: EmailQuestionData) => {
|
||||
setData(newData);
|
||||
|
||||
if (props.onDataChange) {
|
||||
props.onDataChange(newData);
|
||||
}
|
||||
};
|
||||
|
||||
const onInputChange = (fieldName: string) => {
|
||||
return (e: React.FormEvent<HTMLInputElement>) => {
|
||||
const newData = {
|
||||
...data,
|
||||
};
|
||||
|
||||
newData[fieldName] = e.currentTarget.value;
|
||||
|
||||
updateData(newData);
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-5">
|
||||
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
defaultValue={data.label}
|
||||
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="Your Question"
|
||||
onChange={onInputChange("label")}
|
||||
/>
|
||||
{data.required && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
|
||||
*
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative mt-1 max-w-sm rounded-md shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<EnvelopeIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="email"
|
||||
className="block w-full rounded-md border-gray-300 pl-10 text-gray-300 sm:text-sm"
|
||||
defaultValue={data.placeholder}
|
||||
onChange={onInputChange("placeholder")}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="help-text"
|
||||
defaultValue={data.help}
|
||||
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="optional help text"
|
||||
onChange={onInputChange("help")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailQuestionComponent;
|
||||
129
apps/web/components/editorjs/tools/MultipleChoiceQuestion.tsx
Normal file
129
apps/web/components/editorjs/tools/MultipleChoiceQuestion.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
|
||||
import { default as React } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import MultipleChoiceQuestionComponent from "./MultipleChoiceQuestionComponent";
|
||||
|
||||
interface MultipleChoiceQuestionData extends BlockToolData {
|
||||
label: string;
|
||||
help: string;
|
||||
options: string[];
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export default class MultipleChoiceQuestion implements BlockTool {
|
||||
settings: { name: string; icon: string }[];
|
||||
api: API;
|
||||
config: ToolConfig;
|
||||
data: any;
|
||||
readOnly: boolean;
|
||||
block: any;
|
||||
wrapper: undefined | HTMLElement;
|
||||
nodes: { holder: HTMLElement };
|
||||
|
||||
static get toolbox(): { icon: string; title?: string } {
|
||||
return {
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="#000000" d="M8 0c-4.418 0-8 3.582-8 8s3.582 8 8 8 8-3.582 8-8-3.582-8-8-8zM8 14c-3.314 0-6-2.686-6-6s2.686-6 6-6c3.314 0 6 2.686 6 6s-2.686 6-6 6zM5 8c0-1.657 1.343-3 3-3s3 1.343 3 3c0 1.657-1.343 3-3 3s-3-1.343-3-3z"/>
|
||||
</svg>`,
|
||||
title: "Multiple Choice Question",
|
||||
};
|
||||
}
|
||||
|
||||
static get isReadOnlySupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
constructor({
|
||||
data,
|
||||
config,
|
||||
api,
|
||||
readOnly,
|
||||
}: {
|
||||
api: API;
|
||||
config?: ToolConfig;
|
||||
data?: MultipleChoiceQuestionData;
|
||||
block?: any;
|
||||
readOnly: boolean;
|
||||
}) {
|
||||
this.api = api;
|
||||
this.config = config;
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
label: data.label || "",
|
||||
help: data.help || "",
|
||||
options: data.options || [],
|
||||
required: data.required || false,
|
||||
multipleChoice: data.multipleChoice || false,
|
||||
};
|
||||
this.settings = [
|
||||
{
|
||||
name: "required",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
|
||||
},
|
||||
];
|
||||
|
||||
this.nodes = {
|
||||
holder: null,
|
||||
};
|
||||
}
|
||||
|
||||
renderSettings(): HTMLElement {
|
||||
const wrapper = document.createElement("div");
|
||||
|
||||
this.settings.forEach((tune) => {
|
||||
let button = document.createElement("div");
|
||||
|
||||
button.classList.add("cdx-settings-button");
|
||||
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
|
||||
button.innerHTML = tune.icon;
|
||||
wrapper.appendChild(button);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this._toggleTune(tune.name);
|
||||
button.classList.toggle("cdx-settings-button--active");
|
||||
});
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Click on the Settings Button
|
||||
* @param {string} tune — tune name from this.settings
|
||||
*/
|
||||
_toggleTune(tune) {
|
||||
//this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
|
||||
|
||||
if (tune === "required") {
|
||||
this.data.required = !this.data.required;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const rootNode = document.createElement("div");
|
||||
//rootNode.setAttribute("class", this.CSS.wrapper);
|
||||
this.nodes.holder = rootNode;
|
||||
|
||||
const onDataChange = (newData) => {
|
||||
this.data = {
|
||||
...newData,
|
||||
};
|
||||
};
|
||||
|
||||
ReactDOM.render(
|
||||
<MultipleChoiceQuestionComponent
|
||||
onDataChange={onDataChange}
|
||||
readOnly={this.readOnly}
|
||||
data={this.data}
|
||||
/>,
|
||||
rootNode
|
||||
);
|
||||
|
||||
return this.nodes.holder;
|
||||
}
|
||||
|
||||
save() {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { Switch } from "@headlessui/react";
|
||||
import { TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { default as React } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { classNames } from "../../../lib/utils";
|
||||
|
||||
const DEFAULT_INITIAL_DATA = () => {
|
||||
return {
|
||||
label: "",
|
||||
help: "",
|
||||
required: false,
|
||||
multipleChoice: false,
|
||||
options: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
label: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const SingleChoiceQuestion = (props) => {
|
||||
const [choiceData, setChoiceData] = React.useState(
|
||||
props.data.options.length > 0 ? props.data : DEFAULT_INITIAL_DATA
|
||||
);
|
||||
|
||||
const updateData = (newData) => {
|
||||
setChoiceData(newData);
|
||||
if (props.onDataChange) {
|
||||
// Inform editorjs about data change
|
||||
props.onDataChange(newData);
|
||||
}
|
||||
};
|
||||
|
||||
const onAddOption = () => {
|
||||
const newData = {
|
||||
...choiceData,
|
||||
};
|
||||
newData.options.push({
|
||||
id: uuidv4(),
|
||||
label: "",
|
||||
});
|
||||
updateData(newData);
|
||||
};
|
||||
|
||||
const onDeleteOption = (optionIdx) => {
|
||||
const newData = {
|
||||
...choiceData,
|
||||
};
|
||||
newData.options.splice(optionIdx, 1);
|
||||
updateData(newData);
|
||||
};
|
||||
|
||||
const onInputChange = (fieldName) => {
|
||||
return (e) => {
|
||||
const newData = {
|
||||
...choiceData,
|
||||
};
|
||||
newData[fieldName] = e.currentTarget.value;
|
||||
updateData(newData);
|
||||
};
|
||||
};
|
||||
|
||||
const onOptionChange = (index, fieldName) => {
|
||||
return (e) => {
|
||||
const newData = {
|
||||
...choiceData,
|
||||
};
|
||||
newData.options[index][fieldName] = e.currentTarget.value;
|
||||
updateData(newData);
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pb-5">
|
||||
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
defaultValue={choiceData.label}
|
||||
onBlur={onInputChange("label")}
|
||||
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="Your Question"
|
||||
/>
|
||||
{choiceData.required && (
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
|
||||
*
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{choiceData.options.map((option, optionIdx) => (
|
||||
<div key={option.label} className="relative flex items-start pr-2 hover:rounded hover:bg-gray-50">
|
||||
<span className="flex w-full items-center text-sm ">
|
||||
<span
|
||||
className={classNames(
|
||||
choiceData.multipleChoice ? "rounded-sm" : "rounded-full",
|
||||
"flex h-4 w-4 items-center justify-center border border-gray-300"
|
||||
)}
|
||||
aria-hidden="true">
|
||||
<span className="h-1.5 w-1.5 rounded-full" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={option.label}
|
||||
onBlur={onOptionChange(optionIdx, "label")}
|
||||
className="ml-3 w-full border-0 border-transparent bg-transparent p-0 font-medium text-gray-900 outline-none placeholder:text-gray-300 focus:outline-none focus:ring-0"
|
||||
placeholder={`Option ${optionIdx + 1}`}
|
||||
/>
|
||||
{optionIdx !== 0 && (
|
||||
<button onClick={() => onDeleteOption(optionIdx)} className="right-3 pl-4">
|
||||
<TrashIcon className="h-4 w-4 text-gray-300" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="help-text"
|
||||
defaultValue={choiceData.help}
|
||||
onBlur={onInputChange("help")}
|
||||
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="optional help text"
|
||||
/>
|
||||
<div className="relative z-0 mt-2 flex divide-x divide-gray-200">
|
||||
<button
|
||||
className="mr-3 mt-2 inline-flex items-center justify-center rounded border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none"
|
||||
onClick={onAddOption}>
|
||||
Add option
|
||||
</button>
|
||||
<Switch.Group as="div" className="flex items-center pl-3">
|
||||
<Switch
|
||||
checked={choiceData.multipleChoice}
|
||||
onChange={() => {
|
||||
const newData = {
|
||||
...choiceData,
|
||||
};
|
||||
newData.multipleChoice = !newData.multipleChoice;
|
||||
updateData(newData);
|
||||
}}
|
||||
className={classNames(
|
||||
choiceData.multipleChoice ? "bg-red-600" : "bg-gray-200",
|
||||
"relative inline-flex h-4 w-7 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
)}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={classNames(
|
||||
choiceData.multipleChoice ? "translate-x-3" : "translate-x-0",
|
||||
"pointer-events-none inline-block h-3 w-3 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
<Switch.Label as="span" className="ml-3">
|
||||
<span className="text-sm font-medium text-gray-700">Multiple Selection </span>
|
||||
{/* <span className="text-sm text-gray-500">(Save 10%)</span> */}
|
||||
</Switch.Label>
|
||||
</Switch.Group>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SingleChoiceQuestion;
|
||||
120
apps/web/components/editorjs/tools/NumberQuestion.tsx
Normal file
120
apps/web/components/editorjs/tools/NumberQuestion.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
//styles imports in angular.json
|
||||
interface NumberQuestionData extends BlockToolData {
|
||||
label: string;
|
||||
help: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export default class NumberQuestion implements BlockTool {
|
||||
settings: { name: string; icon: string }[];
|
||||
api: API;
|
||||
data: any;
|
||||
wrapper: undefined | HTMLElement;
|
||||
|
||||
static get toolbox(): { icon: string; title?: string } {
|
||||
return {
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9.243 3.03a1 1 0 01.727 1.213L9.53 6h2.94l.56-2.243a1 1 0 111.94.486L14.53 6H17a1 1 0 110 2h-2.97l-1 4H15a1 1 0 110 2h-2.47l-.56 2.242a1 1 0 11-1.94-.485L10.47 14H7.53l-.56 2.242a1 1 0 11-1.94-.485L5.47 14H3a1 1 0 110-2h2.97l1-4H5a1 1 0 110-2h2.47l.56-2.243a1 1 0 011.213-.727zM9.03 8l-1 4h2.938l1-4H9.031z" clip-rule="evenodd" />
|
||||
</svg>`,
|
||||
title: "Number Question",
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data }: { api: API; config?: ToolConfig; data?: NumberQuestionData }) {
|
||||
this.wrapper = undefined;
|
||||
this.settings = [
|
||||
{
|
||||
name: "required",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
|
||||
},
|
||||
];
|
||||
this.data = {
|
||||
label: data.label || "",
|
||||
help: data.help || "",
|
||||
placeholder: data.placeholder || "",
|
||||
required: data.required !== undefined ? data.required : true,
|
||||
};
|
||||
}
|
||||
|
||||
save(block: HTMLDivElement) {
|
||||
return {
|
||||
...this.data,
|
||||
label: (block.firstElementChild.firstElementChild.firstElementChild as HTMLInputElement).value,
|
||||
placeholder: (block.firstElementChild.childNodes[1] as HTMLInputElement).value,
|
||||
help: (block.firstElementChild.lastElementChild as HTMLInputElement).value,
|
||||
};
|
||||
}
|
||||
|
||||
renderSettings(): HTMLElement {
|
||||
const wrapper = document.createElement("div");
|
||||
|
||||
this.settings.forEach((tune) => {
|
||||
let button = document.createElement("div");
|
||||
|
||||
button.classList.add("cdx-settings-button");
|
||||
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
|
||||
button.innerHTML = tune.icon;
|
||||
wrapper.appendChild(button);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this._toggleTune(tune.name);
|
||||
button.classList.toggle("cdx-settings-button--active");
|
||||
});
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Click on the Settings Button
|
||||
* @param {string} tune — tune name from this.settings
|
||||
*/
|
||||
_toggleTune(tune) {
|
||||
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
|
||||
|
||||
if (tune === "required") {
|
||||
this.data.required = !this.data.required;
|
||||
this.wrapper.childNodes[0].childNodes[0].childNodes[1].textContent = this.data.required ? "*" : "";
|
||||
}
|
||||
}
|
||||
|
||||
render(): HTMLElement {
|
||||
this.wrapper = document.createElement("div");
|
||||
const toolView = (
|
||||
<div className="pb-5">
|
||||
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
defaultValue={this.data.label}
|
||||
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="Your Question"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
|
||||
*
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 block w-full max-w-sm rounded-md border-gray-300 text-sm text-gray-400 shadow-sm placeholder:text-gray-300"
|
||||
placeholder="optional placeholder"
|
||||
defaultValue={this.data.placeholder}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="help-text"
|
||||
defaultValue={this.data.help}
|
||||
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="optional help text"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
ReactDOM.render(toolView, this.wrapper);
|
||||
return this.wrapper;
|
||||
}
|
||||
}
|
||||
59
apps/web/components/editorjs/tools/PageTransition.tsx
Normal file
59
apps/web/components/editorjs/tools/PageTransition.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
//styles imports in angular.json
|
||||
interface PageTransitionData extends BlockToolData {
|
||||
submitLabel: string;
|
||||
}
|
||||
|
||||
export default class PageTransition implements BlockTool {
|
||||
submitLabel: string;
|
||||
placeholder: string;
|
||||
api: API;
|
||||
|
||||
/* static get toolbox(): { icon: string; title?: string } {
|
||||
return {
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /> </svg>`,
|
||||
title: "New Page",
|
||||
};
|
||||
} */
|
||||
|
||||
constructor({ data, api }: { api: API; config?: ToolConfig; data?: PageTransitionData }) {
|
||||
this.api = api;
|
||||
this.submitLabel = data.submitLabel || "Submit";
|
||||
}
|
||||
|
||||
save(block: HTMLDivElement) {
|
||||
return {
|
||||
submitLabel: (block.firstElementChild.firstElementChild.firstElementChild as HTMLElement).innerHTML,
|
||||
};
|
||||
}
|
||||
|
||||
render(): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
const toolView = (
|
||||
<div className="relative mt-16 mb-8">
|
||||
<div className="left absolute -top-14 inline-flex items-center rounded-md border border-transparent bg-gray-700 px-4 py-3 text-sm font-medium text-white shadow-sm hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2">
|
||||
<div
|
||||
contentEditable
|
||||
id="label"
|
||||
defaultValue={this.submitLabel}
|
||||
className="border-transparent bg-transparent p-0 ring-0 placeholder:text-opacity-5 focus:border-transparent focus:outline-none focus:ring-0 active:ring-0">
|
||||
{this.submitLabel}
|
||||
</div>
|
||||
{/* <ArrowRightIcon className="w-5 h-5 ml-2 -mr-1" aria-hidden="true" /> */}
|
||||
</div>
|
||||
<div className="relative my-4">
|
||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="bg-white px-2 text-sm text-gray-500">Next Page</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
ReactDOM.render(toolView, container);
|
||||
return container;
|
||||
}
|
||||
}
|
||||
128
apps/web/components/editorjs/tools/PhoneQuestion.tsx
Normal file
128
apps/web/components/editorjs/tools/PhoneQuestion.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
|
||||
import { PhoneIcon } from "@heroicons/react/24/solid";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
//styles imports in angular.json
|
||||
interface PhoneQuestionData extends BlockToolData {
|
||||
label: string;
|
||||
help: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export default class PhoneQuestion implements BlockTool {
|
||||
settings: { name: string; icon: string }[];
|
||||
api: API;
|
||||
data: any;
|
||||
wrapper: undefined | HTMLElement;
|
||||
|
||||
static get toolbox(): { icon: string; title?: string } {
|
||||
return {
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z" />
|
||||
</svg>`,
|
||||
title: "Phone Question",
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data }: { api: API; config?: ToolConfig; data?: PhoneQuestionData }) {
|
||||
this.wrapper = undefined;
|
||||
this.settings = [
|
||||
{
|
||||
name: "required",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
|
||||
},
|
||||
];
|
||||
this.data = {
|
||||
label: data.label || "",
|
||||
help: data.help || "",
|
||||
placeholder: data.placeholder || "+1 (555) 987-6543",
|
||||
required: data.required !== undefined ? data.required : true,
|
||||
};
|
||||
}
|
||||
|
||||
save(block: HTMLDivElement) {
|
||||
return {
|
||||
...this.data,
|
||||
label: (block.firstElementChild.firstElementChild.firstElementChild as HTMLInputElement).value,
|
||||
placeholder: (
|
||||
(block.firstElementChild.childNodes[1] as HTMLInputElement).lastElementChild as HTMLInputElement
|
||||
).value,
|
||||
help: (block.firstElementChild.lastElementChild as HTMLInputElement).value,
|
||||
};
|
||||
}
|
||||
|
||||
renderSettings(): HTMLElement {
|
||||
const wrapper = document.createElement("div");
|
||||
|
||||
this.settings.forEach((tune) => {
|
||||
let button = document.createElement("div");
|
||||
|
||||
button.classList.add("cdx-settings-button");
|
||||
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
|
||||
button.innerHTML = tune.icon;
|
||||
wrapper.appendChild(button);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this._toggleTune(tune.name);
|
||||
button.classList.toggle("cdx-settings-button--active");
|
||||
});
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Click on the Settings Button
|
||||
* @param {string} tune — tune name from this.settings
|
||||
*/
|
||||
_toggleTune(tune) {
|
||||
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
|
||||
|
||||
if (tune === "required") {
|
||||
this.data.required = !this.data.required;
|
||||
this.wrapper.childNodes[0].childNodes[0].childNodes[1].textContent = this.data.required ? "*" : "";
|
||||
}
|
||||
}
|
||||
|
||||
render(): HTMLElement {
|
||||
this.wrapper = document.createElement("div");
|
||||
const toolView = (
|
||||
<div className="pb-5">
|
||||
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
defaultValue={this.data.label}
|
||||
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="Your Question"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
|
||||
*
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-1 max-w-sm rounded-md shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<PhoneIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
className="block w-full rounded-md border-gray-300 pl-10 text-gray-300 sm:text-sm"
|
||||
defaultValue={this.data.placeholder}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="help-text"
|
||||
defaultValue={this.data.help}
|
||||
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="optional help text"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
ReactDOM.render(toolView, this.wrapper);
|
||||
return this.wrapper;
|
||||
}
|
||||
}
|
||||
118
apps/web/components/editorjs/tools/TextQuestion.tsx
Normal file
118
apps/web/components/editorjs/tools/TextQuestion.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
//styles imports in angular.json
|
||||
interface TextQuestionData extends BlockToolData {
|
||||
label: string;
|
||||
help: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export default class TextQuestion implements BlockTool {
|
||||
settings: { name: string; icon: string }[];
|
||||
api: API;
|
||||
data: any;
|
||||
wrapper: undefined | HTMLElement;
|
||||
|
||||
static get toolbox(): { icon: string; title?: string } {
|
||||
return {
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5" /> </svg>`,
|
||||
title: "Text Question",
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data }: { api: API; config?: ToolConfig; data?: TextQuestionData }) {
|
||||
this.wrapper = undefined;
|
||||
this.settings = [
|
||||
{
|
||||
name: "required",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5" /> </svg>`,
|
||||
},
|
||||
];
|
||||
this.data = {
|
||||
label: data.label || "",
|
||||
help: data.help || "",
|
||||
placeholder: data.placeholder || "",
|
||||
required: data.required !== undefined ? data.required : true,
|
||||
};
|
||||
}
|
||||
|
||||
save(block: HTMLDivElement) {
|
||||
return {
|
||||
...this.data,
|
||||
label: (block.firstElementChild.firstElementChild.firstElementChild as HTMLInputElement).value,
|
||||
placeholder: (block.firstElementChild.childNodes[1] as HTMLInputElement).value,
|
||||
help: (block.firstElementChild.lastElementChild as HTMLInputElement).value,
|
||||
};
|
||||
}
|
||||
|
||||
renderSettings(): HTMLElement {
|
||||
const wrapper = document.createElement("div");
|
||||
|
||||
this.settings.forEach((tune) => {
|
||||
let button = document.createElement("div");
|
||||
|
||||
button.classList.add("cdx-settings-button");
|
||||
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
|
||||
button.innerHTML = tune.icon;
|
||||
wrapper.appendChild(button);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this._toggleTune(tune.name);
|
||||
button.classList.toggle("cdx-settings-button--active");
|
||||
});
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Click on the Settings Button
|
||||
* @param {string} tune — tune name from this.settings
|
||||
*/
|
||||
_toggleTune(tune) {
|
||||
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
|
||||
|
||||
if (tune === "required") {
|
||||
this.data.required = !this.data.required;
|
||||
this.wrapper.childNodes[0].childNodes[0].childNodes[1].textContent = this.data.required ? "*" : "";
|
||||
}
|
||||
}
|
||||
|
||||
render(): HTMLElement {
|
||||
this.wrapper = document.createElement("div");
|
||||
const toolView = (
|
||||
<div className="pb-5">
|
||||
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
defaultValue={this.data.label}
|
||||
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="Your Question"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
|
||||
*
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
className="mt-1 block w-full max-w-sm rounded-md border-gray-300 text-sm text-gray-400 shadow-sm placeholder:text-gray-300"
|
||||
placeholder="optional placeholder"
|
||||
defaultValue={this.data.placeholder}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="help-text"
|
||||
defaultValue={this.data.help}
|
||||
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="optional help text"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
ReactDOM.render(toolView, this.wrapper);
|
||||
return this.wrapper;
|
||||
}
|
||||
}
|
||||
118
apps/web/components/editorjs/tools/TextareaQuestion.tsx
Normal file
118
apps/web/components/editorjs/tools/TextareaQuestion.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
//styles imports in angular.json
|
||||
interface TextareaQuestionData extends BlockToolData {
|
||||
label: string;
|
||||
help: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export default class TextareaQuestion implements BlockTool {
|
||||
settings: { name: string; icon: string }[];
|
||||
api: API;
|
||||
data: any;
|
||||
wrapper: undefined | HTMLElement;
|
||||
|
||||
static get toolbox(): { icon: string; title?: string } {
|
||||
return {
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-justify"><line x1="21" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="21" y1="18" x2="3" y2="18"></line></svg>`,
|
||||
title: "Long Text Question",
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data }: { api: API; config?: ToolConfig; data?: TextareaQuestionData }) {
|
||||
this.wrapper = undefined;
|
||||
this.settings = [
|
||||
{
|
||||
name: "required",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
|
||||
},
|
||||
];
|
||||
this.data = {
|
||||
label: data.label || "",
|
||||
help: data.help || "",
|
||||
placeholder: data.placeholder || "",
|
||||
required: data.required !== undefined ? data.required : true,
|
||||
};
|
||||
}
|
||||
|
||||
save(block: HTMLDivElement) {
|
||||
return {
|
||||
...this.data,
|
||||
label: (block.firstElementChild.firstElementChild.firstElementChild as HTMLInputElement).value,
|
||||
placeholder: (block.firstElementChild.childNodes[1] as HTMLInputElement).value,
|
||||
help: (block.firstElementChild.lastElementChild as HTMLInputElement).value,
|
||||
};
|
||||
}
|
||||
|
||||
renderSettings(): HTMLElement {
|
||||
const wrapper = document.createElement("div");
|
||||
|
||||
this.settings.forEach((tune) => {
|
||||
let button = document.createElement("div");
|
||||
|
||||
button.classList.add("cdx-settings-button");
|
||||
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
|
||||
button.innerHTML = tune.icon;
|
||||
wrapper.appendChild(button);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this._toggleTune(tune.name);
|
||||
button.classList.toggle("cdx-settings-button--active");
|
||||
});
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Click on the Settings Button
|
||||
* @param {string} tune — tune name from this.settings
|
||||
*/
|
||||
_toggleTune(tune) {
|
||||
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
|
||||
|
||||
if (tune === "required") {
|
||||
this.data.required = !this.data.required;
|
||||
this.wrapper.childNodes[0].childNodes[0].childNodes[1].textContent = this.data.required ? "*" : "";
|
||||
}
|
||||
}
|
||||
|
||||
render(): HTMLElement {
|
||||
this.wrapper = document.createElement("div");
|
||||
const toolView = (
|
||||
<div className="pb-5">
|
||||
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
defaultValue={this.data.label}
|
||||
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="Your Question"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
|
||||
*
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
rows={4}
|
||||
className="mt-1 block w-full max-w-sm rounded-md border-gray-300 text-sm text-gray-400 shadow-sm placeholder:text-gray-300"
|
||||
placeholder="optional placeholder"
|
||||
defaultValue={this.data.placeholder}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id="help-text"
|
||||
defaultValue={this.data.help}
|
||||
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="optional help text"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
ReactDOM.render(toolView, this.wrapper);
|
||||
return this.wrapper;
|
||||
}
|
||||
}
|
||||
128
apps/web/components/editorjs/tools/WebsiteQuestion.tsx
Normal file
128
apps/web/components/editorjs/tools/WebsiteQuestion.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
|
||||
import { GlobeAltIcon } from "@heroicons/react/24/solid";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
//styles imports in angular.json
|
||||
interface WebsiteQuestionData extends BlockToolData {
|
||||
label: string;
|
||||
help: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export default class WebsiteQuestion implements BlockTool {
|
||||
settings: { name: string; icon: string }[];
|
||||
api: API;
|
||||
data: any;
|
||||
wrapper: undefined | HTMLElement;
|
||||
|
||||
static get toolbox(): { icon: string; title?: string } {
|
||||
return {
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.083 9h1.946c.089-1.546.383-2.97.837-4.118A6.004 6.004 0 004.083 9zM10 2a8 8 0 100 16 8 8 0 000-16zm0 2c-.076 0-.232.032-.465.262-.238.234-.497.623-.737 1.182-.389.907-.673 2.142-.766 3.556h3.936c-.093-1.414-.377-2.649-.766-3.556-.24-.56-.5-.948-.737-1.182C10.232 4.032 10.076 4 10 4zm3.971 5c-.089-1.546-.383-2.97-.837-4.118A6.004 6.004 0 0115.917 9h-1.946zm-2.003 2H8.032c.093 1.414.377 2.649.766 3.556.24.56.5.948.737 1.182.233.23.389.262.465.262.076 0 .232-.032.465-.262.238-.234.498-.623.737-1.182.389-.907.673-2.142.766-3.556zm1.166 4.118c.454-1.147.748-2.572.837-4.118h1.946a6.004 6.004 0 01-2.783 4.118zm-6.268 0C6.412 13.97 6.118 12.546 6.03 11H4.083a6.004 6.004 0 002.783 4.118z" clip-rule="evenodd" />
|
||||
</svg>`,
|
||||
title: "Website Question",
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data }: { api: API; config?: ToolConfig; data?: WebsiteQuestionData }) {
|
||||
this.wrapper = undefined;
|
||||
this.settings = [
|
||||
{
|
||||
name: "required",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
|
||||
},
|
||||
];
|
||||
this.data = {
|
||||
label: data.label || "",
|
||||
help: data.help || "",
|
||||
placeholder: data.placeholder || "https://",
|
||||
required: data.required !== undefined ? data.required : true,
|
||||
};
|
||||
}
|
||||
|
||||
save(block: HTMLDivElement) {
|
||||
return {
|
||||
...this.data,
|
||||
label: (block.firstElementChild.firstElementChild.firstElementChild as HTMLInputElement).value,
|
||||
placeholder: (
|
||||
(block.firstElementChild.childNodes[1] as HTMLInputElement).lastElementChild as HTMLInputElement
|
||||
).value,
|
||||
help: (block.firstElementChild.lastElementChild as HTMLInputElement).value,
|
||||
};
|
||||
}
|
||||
|
||||
renderSettings(): HTMLElement {
|
||||
const wrapper = document.createElement("div");
|
||||
|
||||
this.settings.forEach((tune) => {
|
||||
let button = document.createElement("div");
|
||||
|
||||
button.classList.add("cdx-settings-button");
|
||||
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
|
||||
button.innerHTML = tune.icon;
|
||||
wrapper.appendChild(button);
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
this._toggleTune(tune.name);
|
||||
button.classList.toggle("cdx-settings-button--active");
|
||||
});
|
||||
});
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Click on the Settings Button
|
||||
* @param {string} tune — tune name from this.settings
|
||||
*/
|
||||
_toggleTune(tune) {
|
||||
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
|
||||
|
||||
if (tune === "required") {
|
||||
this.data.required = !this.data.required;
|
||||
this.wrapper.childNodes[0].childNodes[0].childNodes[1].textContent = this.data.required ? "*" : "";
|
||||
}
|
||||
}
|
||||
|
||||
render(): HTMLElement {
|
||||
this.wrapper = document.createElement("div");
|
||||
const toolView = (
|
||||
<div className="pb-5">
|
||||
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
|
||||
<input
|
||||
type="text"
|
||||
id="label"
|
||||
defaultValue={this.data.label}
|
||||
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="Your Question"
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
|
||||
*
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative mt-1 max-w-sm rounded-md shadow-sm">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<GlobeAltIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="website"
|
||||
className="block w-full rounded-md border-gray-300 pl-10 text-gray-300 sm:text-sm"
|
||||
defaultValue={this.data.placeholder}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="help-text"
|
||||
defaultValue={this.data.help}
|
||||
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
|
||||
placeholder="optional help text"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
ReactDOM.render(toolView, this.wrapper);
|
||||
return this.wrapper;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DocumentSearchIcon } from "@heroicons/react/outline";
|
||||
import { DocumentMagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
import { FaReact, FaVuejs } from "react-icons/fa";
|
||||
import { toast } from "react-toastify";
|
||||
@@ -36,7 +36,7 @@ export default function FormCode({ formId }) {
|
||||
name: "Docs",
|
||||
href: "https://docs.snoopforms.com",
|
||||
bgColor: "bg-ui-gray-dark",
|
||||
icon: DocumentSearchIcon,
|
||||
icon: DocumentMagnifyingGlassIcon,
|
||||
target: "_blank",
|
||||
},
|
||||
];
|
||||
@@ -44,26 +44,24 @@ export default function FormCode({ formId }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto mt-8">
|
||||
<h1 className="text-3xl font-bold leading-tight text-ui-gray-dark">
|
||||
Connect your form
|
||||
</h1>
|
||||
<h1 className="text-ui-gray-dark text-3xl font-bold leading-tight">Connect your form</h1>
|
||||
</div>
|
||||
<div className="mt-4 mb-12">
|
||||
<p className="text-ui-gray-dark">
|
||||
To send all form submissions to this dashboard, update the form ID in
|
||||
the <code>{"<snoopForm>"}</code> component.
|
||||
To send all form submissions to this dashboard, update the form ID in the{" "}
|
||||
<code>{"<snoopForm>"}</code> component.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
<label htmlFor="formId" className="block text-base text-ui-gray-dark">
|
||||
<label htmlFor="formId" className="text-ui-gray-dark block text-base">
|
||||
Your form ID
|
||||
</label>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
id="formId"
|
||||
type="text"
|
||||
className="w-full mb-3 border-gray-300 rounded-sm shadow-sm text-md disabled:bg-gray-100"
|
||||
className="text-md mb-3 w-full rounded-sm border-gray-300 shadow-sm disabled:bg-gray-100"
|
||||
value={formId}
|
||||
disabled
|
||||
/>
|
||||
@@ -73,13 +71,12 @@ export default function FormCode({ formId }) {
|
||||
navigator.clipboard.writeText(formId);
|
||||
toast("Copied form ID to clipboard");
|
||||
}}
|
||||
fullwidth
|
||||
>
|
||||
fullwidth>
|
||||
copy
|
||||
</StandardButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-8 font-light text-gray-200 bg-black rounded-md">
|
||||
<div className="rounded-md bg-black p-8 font-light text-gray-200">
|
||||
<p>
|
||||
<code>
|
||||
{"<"}
|
||||
@@ -91,13 +88,10 @@ export default function FormCode({ formId }) {
|
||||
<code>{`domain="${window?.location.host}"`}</code>
|
||||
</p>
|
||||
<p>
|
||||
<code>{`protocol="${window?.location.protocol.replace(
|
||||
":",
|
||||
""
|
||||
)}"`}</code>
|
||||
<code>{`protocol="${window?.location.protocol.replace(":", "")}"`}</code>
|
||||
</p>
|
||||
<p>
|
||||
<code>{`formId=${formId}`}</code>
|
||||
<code>{`formId="${formId}"`}</code>
|
||||
</p>
|
||||
<p>
|
||||
<code>{">"}</code>
|
||||
@@ -115,73 +109,58 @@ export default function FormCode({ formId }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-16">
|
||||
<h2 className="text-xl font-bold text-ui-gray-dark">Code your form</h2>
|
||||
<h2 className="text-ui-gray-dark text-xl font-bold">Code your form</h2>
|
||||
<div className="mt-4 mb-12">
|
||||
<p className="text-ui-gray-dark">
|
||||
Build your form with the code library of your choice. Manage your
|
||||
data in this dashboard.
|
||||
Build your form with the code library of your choice. Manage your data in this dashboard.
|
||||
</p>
|
||||
</div>
|
||||
<ul
|
||||
role="list"
|
||||
className="grid grid-cols-1 gap-5 mt-3 sm:gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<ul role="list" className="mt-3 grid grid-cols-1 gap-5 sm:grid-cols-2 sm:gap-6">
|
||||
{libs.map((lib) => (
|
||||
<a
|
||||
className="flex col-span-1 rounded-md shadow-sm"
|
||||
key={lib.id}
|
||||
href={lib.href}
|
||||
target={lib.target || ""}
|
||||
rel="noreferrer"
|
||||
>
|
||||
<li
|
||||
className={classNames(
|
||||
lib.comingSoon
|
||||
? "text-ui-gray-medium"
|
||||
: "shadow-sm text-ui-gray-dark hover:text-black",
|
||||
"flex col-span-1 rounded-md w-full"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
<Link key={lib.id} href={lib.href}>
|
||||
<a className="col-span-1 flex rounded-md shadow-sm" target={lib.target || ""} rel="noreferrer">
|
||||
<li
|
||||
className={classNames(
|
||||
lib.bgColor,
|
||||
"flex-shrink-0 flex items-center justify-center w-20 text-white text-sm font-medium rounded-l-md"
|
||||
)}
|
||||
>
|
||||
<lib.icon
|
||||
lib.comingSoon ? "text-ui-gray-medium" : "text-ui-gray-dark shadow-sm hover:text-black",
|
||||
"col-span-1 flex w-full rounded-md"
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
lib.comingSoon
|
||||
? "text-ui-gray-medium"
|
||||
: "text-white stroke-1",
|
||||
"w-10 h-10"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
lib.comingSoon ? "border-dashed" : "",
|
||||
"flex items-center justify-between flex-1 truncate bg-white rounded-r-md"
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex px-4 py-6 text-lg truncate">
|
||||
<p className="font-light">{lib.name}</p>
|
||||
{lib.comingSoon && (
|
||||
<div className="p-1 px-3 ml-3 bg-green-100 rounded">
|
||||
<p className="text-xs text-black">coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
lib.bgColor,
|
||||
"flex w-20 flex-shrink-0 items-center justify-center rounded-l-md text-sm font-medium text-white"
|
||||
)}>
|
||||
<lib.icon
|
||||
className={classNames(
|
||||
lib.comingSoon ? "text-ui-gray-medium" : "stroke-1 text-white",
|
||||
"h-10 w-10"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</a>
|
||||
<div
|
||||
className={classNames(
|
||||
lib.comingSoon ? "border-dashed" : "",
|
||||
"flex flex-1 items-center justify-between truncate rounded-r-md bg-white"
|
||||
)}>
|
||||
<div className="inline-flex truncate px-4 py-6 text-lg">
|
||||
<p className="font-light">{lib.name}</p>
|
||||
{lib.comingSoon && (
|
||||
<div className="ml-3 rounded bg-green-100 p-1 px-3">
|
||||
<p className="text-xs text-black">coming soon</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="my-12 font-light text-center text-ui-gray-medium">
|
||||
<div className="text-ui-gray-medium my-12 text-center font-light">
|
||||
<p>
|
||||
Your form is running? Go to{" "}
|
||||
<Link href={`/forms/${formId}/preview`}>
|
||||
<a className="underline text-red">Pipelines</a>
|
||||
<a className="text-red underline">Pipelines</a>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { Dialog, RadioGroup, Transition } from "@headlessui/react";
|
||||
import { CheckCircleIcon, XIcon } from "@heroicons/react/solid";
|
||||
import { CheckCircleIcon, XMarkIcon } from "@heroicons/react/24/solid";
|
||||
import { useRouter } from "next/router";
|
||||
import { Fragment, useState } from "react";
|
||||
import { BsPlus } from "react-icons/bs";
|
||||
@@ -13,14 +13,12 @@ const formTypes = [
|
||||
{
|
||||
id: "NOCODE",
|
||||
title: "No-Code Builder",
|
||||
description:
|
||||
"Use the Notion-like builder to build your form without a single line of code.",
|
||||
description: "Use the Notion-like builder to build your form without a single line of code.",
|
||||
},
|
||||
{
|
||||
id: "CODE",
|
||||
title: "Code",
|
||||
description:
|
||||
"Use the snoopReact library to code the form yourself and manage the data here.",
|
||||
description: "Use the snoopReact library to code the form yourself and manage the data here.",
|
||||
additionalDescription: "",
|
||||
},
|
||||
];
|
||||
@@ -30,10 +28,7 @@ type FormOnboardingModalProps = {
|
||||
setOpen: (v: boolean) => void;
|
||||
};
|
||||
|
||||
export default function NewFormModal({
|
||||
open,
|
||||
setOpen,
|
||||
}: FormOnboardingModalProps) {
|
||||
export default function NewFormModal({ open, setOpen }: FormOnboardingModalProps) {
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState("");
|
||||
const [formType, setFormType] = useState(formTypes[0]);
|
||||
@@ -60,13 +55,12 @@ export default function NewFormModal({
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-30 backdrop-blur-md" />
|
||||
leaveTo="opacity-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-30 backdrop-blur-md transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
@@ -74,40 +68,32 @@ export default function NewFormModal({
|
||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative px-4 pt-5 pb-4 text-left transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:max-w-lg sm:w-full sm:p-6">
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
|
||||
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 bg-white rounded-md hover:text-gray-500 focus:outline-none focus:ring-0 focus:ring-offset-2"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-0 focus:ring-offset-2"
|
||||
onClick={() => setOpen(false)}>
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon className="w-6 h-6" aria-hidden="true" />
|
||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between">
|
||||
<h2 className="flex-none p-2 text-xl font-bold text-ui-gray-dark">
|
||||
Create new form
|
||||
</h2>
|
||||
<h2 className="text-ui-gray-dark flex-none p-2 text-xl font-bold">Create new form</h2>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => createFormAction(e)}
|
||||
className="inline-block w-full p-2 overflow-hidden text-left align-bottom transition-all transform sm:align-middle"
|
||||
>
|
||||
className="inline-block w-full transform overflow-hidden p-2 text-left align-bottom transition-all sm:align-middle">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="text-sm font-light text-ui-gray-dark"
|
||||
>
|
||||
<label htmlFor="email" className="text-ui-gray-dark text-sm font-light">
|
||||
Name your form
|
||||
</label>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className="block w-full p-2 mb-6 border-none rounded bg-ui-gray-light focus:ring-2 focus:ring-red sm:text-sm placeholder:font-extralight placeholder:text-ui-gray-medium"
|
||||
className="bg-ui-gray-light focus:ring-red placeholder:text-ui-gray-medium mb-6 block w-full rounded border-none p-2 placeholder:font-extralight focus:ring-2 sm:text-sm"
|
||||
placeholder="e.g. Customer Research Survey"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
@@ -118,11 +104,11 @@ export default function NewFormModal({
|
||||
</div>
|
||||
|
||||
<RadioGroup value={formType} onChange={setFormType}>
|
||||
<RadioGroup.Label className="text-sm font-light text-ui-gray-dark">
|
||||
<RadioGroup.Label className="text-ui-gray-dark text-sm font-light">
|
||||
How do you build your form?
|
||||
</RadioGroup.Label>
|
||||
|
||||
<div className="grid grid-cols-1 mt-4 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
|
||||
<div className="mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
|
||||
{formTypes.map((formType) => (
|
||||
<RadioGroup.Option
|
||||
key={formType.id}
|
||||
@@ -130,42 +116,34 @@ export default function NewFormModal({
|
||||
className={({ checked, active }) =>
|
||||
classNames(
|
||||
checked ? "border-transparent" : "",
|
||||
active
|
||||
? "border-red ring-2 ring-red"
|
||||
: "bg-ui-gray-lighter",
|
||||
"relative bg-white border rounded shadow-sm p-4 flex cursor-pointer focus:outline-none"
|
||||
active ? "border-red ring-red ring-2" : "bg-ui-gray-lighter",
|
||||
"relative flex cursor-pointer rounded border bg-white p-4 shadow-sm focus:outline-none"
|
||||
)
|
||||
}
|
||||
>
|
||||
}>
|
||||
{({ checked, active }) => (
|
||||
<>
|
||||
<span className="flex flex-1">
|
||||
<span className="flex flex-col">
|
||||
<RadioGroup.Label
|
||||
as="span"
|
||||
className="block font-bold text-md text-ui-gray-dark"
|
||||
>
|
||||
className="text-md text-ui-gray-dark block font-bold">
|
||||
{formType.title}
|
||||
</RadioGroup.Label>
|
||||
<RadioGroup.Description
|
||||
as="span"
|
||||
className="flex items-center mt-1 text-xs whitespace-pre-wrap text-ui-gray-dark"
|
||||
>
|
||||
className="text-ui-gray-dark mt-1 flex items-center whitespace-pre-wrap text-xs">
|
||||
{formType.description}
|
||||
</RadioGroup.Description>
|
||||
</span>
|
||||
</span>
|
||||
<CheckCircleIcon
|
||||
className={classNames(
|
||||
!checked ? "hidden" : "",
|
||||
"h-5 w-5 text-red"
|
||||
)}
|
||||
className={classNames(!checked ? "hidden" : "", "text-red h-5 w-5")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className={classNames(
|
||||
checked ? "hidden" : "",
|
||||
"h-4 w-4 rounded-full border-2 border-ui-gray-light"
|
||||
"border-ui-gray-light h-4 w-4 rounded-full border-2"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -173,7 +151,7 @@ export default function NewFormModal({
|
||||
className={classNames(
|
||||
active ? "border" : "border-2",
|
||||
checked ? "border-red" : "border-transparent",
|
||||
"absolute -inset-px rounded pointer-events-none"
|
||||
"pointer-events-none absolute -inset-px rounded"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -186,7 +164,7 @@ export default function NewFormModal({
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<StandardButton fullwidth type="submit">
|
||||
create form
|
||||
<BsPlus className="w-6 h-6 ml-1"></BsPlus>
|
||||
<BsPlus className="ml-1 h-6 w-6"></BsPlus>
|
||||
</StandardButton>
|
||||
</div>
|
||||
</form>
|
||||
184
apps/web/components/frontend/App.tsx
Normal file
184
apps/web/components/frontend/App.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { GlobeAltIcon, EnvelopeIcon, PhoneIcon } from "@heroicons/react/24/solid";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { SnoopElement, SnoopForm, SnoopPage } from "@snoopforms/react";
|
||||
import { useMemo } from "react";
|
||||
import { generateId } from "../../lib/utils";
|
||||
import Loading from "../Loading";
|
||||
|
||||
export default function App({ id = "", formId, blocks, localOnly = false }) {
|
||||
const pages = useMemo(() => {
|
||||
const pages = [];
|
||||
let currentPage = {
|
||||
id: formId, // give the first page the formId as id by default
|
||||
blocks: [],
|
||||
};
|
||||
for (const block of blocks) {
|
||||
if (block.type !== "pageTransition") {
|
||||
currentPage.blocks.push(block);
|
||||
} else {
|
||||
currentPage.blocks.push({
|
||||
id: generateId(10),
|
||||
data: {
|
||||
label: block.data.submitLabel,
|
||||
},
|
||||
type: "submitButton",
|
||||
});
|
||||
pages.push(currentPage);
|
||||
currentPage = {
|
||||
id: block.id,
|
||||
blocks: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
pages.push(currentPage);
|
||||
return pages;
|
||||
}, [blocks, formId]);
|
||||
|
||||
if (!pages) return <Loading />;
|
||||
|
||||
return (
|
||||
<div className="w-full px-5 py-5">
|
||||
<SnoopForm
|
||||
key={id} // used to reset form
|
||||
domain={window.location.host}
|
||||
protocol={window.location.protocol === "http:" ? "http" : "https"}
|
||||
formId={formId}
|
||||
localOnly={localOnly}
|
||||
className="mx-auto w-full max-w-3xl space-y-6">
|
||||
{pages.map((page, pageIdx) => (
|
||||
<SnoopPage key={page.id} name={page.id} thankyou={pageIdx === pages.length - 1}>
|
||||
{page.blocks.map((block) => (
|
||||
<div key={block.id}>
|
||||
{block.type === "paragraph" ? (
|
||||
<div
|
||||
className="ce-paragraph"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(block.data.text),
|
||||
}}></div>
|
||||
) : block.type === "header" ? (
|
||||
block.data.level === 1 ? (
|
||||
<h1 className="ce-header">{block.data.text}</h1>
|
||||
) : block.level === 2 ? (
|
||||
<h2 className="ce-header">{block.data.text}</h2>
|
||||
) : block.data.level === 3 ? (
|
||||
<h3 className="ce-header">{block.data.text}</h3>
|
||||
) : null
|
||||
) : block.type === "textQuestion" ? (
|
||||
<SnoopElement
|
||||
type="text"
|
||||
name={block.id}
|
||||
label={block.data.label}
|
||||
help={block.data.help}
|
||||
placeholder={block.data.placeholder}
|
||||
classNames={{
|
||||
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
|
||||
}}
|
||||
required={block.data.required}
|
||||
/>
|
||||
) : block.type === "textareaQuestion" ? (
|
||||
<SnoopElement
|
||||
type="textarea"
|
||||
rows={4}
|
||||
name={block.id}
|
||||
label={block.data.label}
|
||||
help={block.data.help}
|
||||
placeholder={block.data.placeholder}
|
||||
classNames={{
|
||||
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
|
||||
}}
|
||||
required={block.data.required}
|
||||
/>
|
||||
) : block.type === "emailQuestion" ? (
|
||||
<SnoopElement
|
||||
type="email"
|
||||
name={block.id}
|
||||
label={block.data.label}
|
||||
help={block.data.help}
|
||||
placeholder={block.data.placeholder}
|
||||
icon={<EnvelopeIcon className="h-5 w-5" />}
|
||||
classNames={{
|
||||
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
|
||||
}}
|
||||
required={block.data.required}
|
||||
/>
|
||||
) : block.type === "multipleChoiceQuestion" && block.data.multipleChoice ? (
|
||||
<SnoopElement
|
||||
type="checkbox"
|
||||
name={block.id}
|
||||
label={block.data.label}
|
||||
help={block.data.help}
|
||||
options={block.data.options.map((o) => o.label)}
|
||||
classNames={{
|
||||
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
|
||||
}}
|
||||
required={block.data.required}
|
||||
/>
|
||||
) : block.type === "multipleChoiceQuestion" && !block.data.multipleChoice ? (
|
||||
<SnoopElement
|
||||
type="radio"
|
||||
name={block.id}
|
||||
label={block.data.label}
|
||||
help={block.data.help}
|
||||
options={block.data.options.map((o) => o.label)}
|
||||
classNames={{
|
||||
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
|
||||
}}
|
||||
required={block.data.required}
|
||||
/>
|
||||
) : block.type === "numberQuestion" ? (
|
||||
<SnoopElement
|
||||
type="number"
|
||||
name={block.id}
|
||||
label={block.data.label}
|
||||
help={block.data.help}
|
||||
placeholder={block.data.placeholder}
|
||||
classNames={{
|
||||
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
|
||||
}}
|
||||
required={block.data.required}
|
||||
/>
|
||||
) : block.type === "phoneQuestion" ? (
|
||||
<SnoopElement
|
||||
type="phone"
|
||||
name={block.id}
|
||||
label={block.data.label}
|
||||
help={block.data.help}
|
||||
placeholder={block.data.placeholder}
|
||||
icon={<PhoneIcon className="h-5 w-5" />}
|
||||
classNames={{
|
||||
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
|
||||
}}
|
||||
required={block.data.required}
|
||||
/>
|
||||
) : block.type === "submitButton" ? (
|
||||
<SnoopElement
|
||||
name="submit"
|
||||
type="submit"
|
||||
label={block.data.label}
|
||||
classNames={{
|
||||
button:
|
||||
"inline-flex items-center px-4 py-3 text-sm font-medium text-white bg-gray-700 border border-transparent rounded-md shadow-sm hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500",
|
||||
}}
|
||||
/>
|
||||
) : block.type === "websiteQuestion" ? (
|
||||
<SnoopElement
|
||||
type="website"
|
||||
name={block.id}
|
||||
label={block.data.label}
|
||||
help={block.data.help}
|
||||
placeholder={block.data.placeholder}
|
||||
icon={<GlobeAltIcon className="h-5 w-5" />}
|
||||
classNames={{
|
||||
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
|
||||
}}
|
||||
required={block.data.required}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</SnoopPage>
|
||||
))}
|
||||
</SnoopForm>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
63
apps/web/components/layout/BaseLayoutManagement.tsx
Normal file
63
apps/web/components/layout/BaseLayoutManagement.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Head from "next/head";
|
||||
import { classNames } from "../../lib/utils";
|
||||
import MenuBreadcrumbs from "./MenuBreadcrumbs";
|
||||
import MenuProfile from "./MenuProfile";
|
||||
import MenuSteps from "./MenuSteps";
|
||||
import NewFormNavButton from "./NewFormNavButton";
|
||||
|
||||
interface BaseLayoutManagementProps {
|
||||
title: string;
|
||||
breadcrumbs: any;
|
||||
steps?: any;
|
||||
currentStep?: string;
|
||||
children: React.ReactNode;
|
||||
bgClass?: string;
|
||||
limitHeightScreen?: boolean;
|
||||
}
|
||||
|
||||
export default function BaseLayoutManagement({
|
||||
title,
|
||||
breadcrumbs,
|
||||
steps,
|
||||
currentStep,
|
||||
children,
|
||||
bgClass = "bg-ui-gray-lighter",
|
||||
limitHeightScreen = false,
|
||||
}: BaseLayoutManagementProps) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
<div
|
||||
className={classNames(
|
||||
bgClass,
|
||||
limitHeightScreen ? "h-screen max-h-screen overflow-hidden" : "min-h-screen",
|
||||
"flex h-full"
|
||||
)}>
|
||||
<div
|
||||
className={classNames(limitHeightScreen ? "max-h-full" : "h-full", "flex w-full flex-1 flex-col")}>
|
||||
<header className="w-full">
|
||||
<div className="border-ui-gray-light relative z-10 flex h-16 flex-shrink-0 border-b bg-white shadow-sm">
|
||||
<div className="grid w-full grid-cols-2 sm:grid-cols-3">
|
||||
<div className="hidden flex-1 space-x-8 sm:flex">
|
||||
<NewFormNavButton />
|
||||
<MenuBreadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
<div className="flex flex-1">
|
||||
{steps && <MenuSteps steps={steps} currentStep={currentStep} />}
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-end space-x-2 text-right sm:space-x-4">
|
||||
<div className="mr-6">
|
||||
<MenuProfile />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
apps/web/components/layout/BaseLayoutUnauthorized.tsx
Normal file
12
apps/web/components/layout/BaseLayoutUnauthorized.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import Head from "next/head";
|
||||
|
||||
export default function BaseLayoutUnauthorized({ title, children }) {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -23,15 +23,11 @@ const EmptyPageFiller: React.FC<Props> = ({
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
`bg-white border border-ui-gray-light text-center p-8 mx-auto mt-8 rounded-lg ` +
|
||||
borderStyles
|
||||
}
|
||||
>
|
||||
`border-ui-gray-light mx-auto mt-8 rounded-lg border bg-white p-8 text-center ` + borderStyles
|
||||
}>
|
||||
{children}
|
||||
<h3 className="mt-5 text-base font-bold text-ui-gray-medium">
|
||||
{alertText}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs font-light text-ui-gray-medium">{hintText}</p>
|
||||
<h3 className="text-ui-gray-medium mt-5 text-base font-bold">{alertText}</h3>
|
||||
<p className="text-ui-gray-medium mt-1 text-xs font-light">{hintText}</p>
|
||||
{hasButton && (
|
||||
<div className="mt-6">
|
||||
<StandardButton onClick={onClick}>{buttonText}</StandardButton>
|
||||
@@ -3,7 +3,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const FullWidth: React.FC<Props> = ({ children }) => {
|
||||
return <main className="w-full h-full">{children}</main>;
|
||||
return <main className="h-full w-full">{children}</main>;
|
||||
};
|
||||
|
||||
export default FullWidth;
|
||||
@@ -1,7 +1,7 @@
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowLeftIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import { ArrowLeftIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
import { useSession, signIn } from "next-auth/react";
|
||||
import Loading from "../Loading";
|
||||
|
||||
@@ -23,31 +23,25 @@ export default function LayoutShare({ formId, resetApp, children }) {
|
||||
<title>Form Preview</title>
|
||||
</Head>
|
||||
<div className="flex min-h-screen overflow-hidden bg-gray-50">
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<header className="w-full">
|
||||
<div className="relative z-10 flex flex-shrink-0 h-16 bg-white border-b border-ui-gray-light shadow-sm">
|
||||
<div className="border-ui-gray-light relative z-10 flex h-16 flex-shrink-0 border-b bg-white shadow-sm">
|
||||
<div className="flex flex-1 px-4 sm:px-6">
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="flex flex-1 items-center">
|
||||
<Link href={`/forms/${formId}/form`}>
|
||||
<a>
|
||||
<ArrowLeftIcon className="w-6 h-6" aria-hidden="true" />
|
||||
<ArrowLeftIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="flex items-center justify-center flex-1 text-gray-600">
|
||||
Preview
|
||||
</p>
|
||||
<div className="flex items-center justify-end flex-1 space-x-2 text-right sm:ml-6 sm:space-x-4">
|
||||
<p className="flex flex-1 items-center justify-center text-gray-600">Preview</p>
|
||||
<div className="flex flex-1 items-center justify-end space-x-2 text-right sm:ml-6 sm:space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetApp()}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white border border-transparent rounded-md shadow-sm bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
className="inline-flex items-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
Restart
|
||||
<RefreshIcon
|
||||
className="w-5 h-5 ml-2 -mr-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ArrowPathIcon className="ml-2 -mr-1 h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,7 +3,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const LimitedWidth: React.FC<Props> = ({ children }) => {
|
||||
return <main className="w-full h-full max-w-5xl mx-auto">{children}</main>;
|
||||
return <main className="mx-auto h-full w-full max-w-5xl">{children}</main>;
|
||||
};
|
||||
|
||||
export default LimitedWidth;
|
||||
@@ -1,19 +1,16 @@
|
||||
import { HomeIcon } from "@heroicons/react/outline";
|
||||
import { HomeIcon } from "@heroicons/react/24/outline";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function MenuBreadcrumbs({ breadcrumbs }) {
|
||||
return (
|
||||
<div className="hidden sm:flex sm:flex-1">
|
||||
<div className="hidden overflow-hidden text-ellipsis sm:flex sm:flex-1">
|
||||
<nav className="hidden lg:flex" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center space-x-4">
|
||||
<li>
|
||||
<div>
|
||||
<Link href="/forms/">
|
||||
<a className="text-ui-gray-dark hover:text-ui-gray-dark">
|
||||
<HomeIcon
|
||||
className="flex-shrink-0 w-5 h-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<HomeIcon className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="sr-only">Home</span>
|
||||
</a>
|
||||
</Link>
|
||||
@@ -23,18 +20,16 @@ export default function MenuBreadcrumbs({ breadcrumbs }) {
|
||||
<li key={crumb.name}>
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="flex-shrink-0 w-5 h-5 text-ui-gray-medium"
|
||||
className="text-ui-gray-medium h-5 w-5 flex-shrink-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
aria-hidden="true"
|
||||
>
|
||||
aria-hidden="true">
|
||||
<path d="M5.555 17.776l8-16 .894.448-8 16-.894-.448z" />
|
||||
</svg>
|
||||
<a
|
||||
href={crumb.href}
|
||||
className="ml-4 text-sm font-medium text-ui-gray-dark hover:text-ui-gray-dark"
|
||||
>
|
||||
className="text-ui-gray-dark hover:text-ui-gray-dark ml-4 truncate text-sm font-medium">
|
||||
{crumb.name}
|
||||
</a>
|
||||
</div>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { ArrowLeftOnRectangleIcon } from "@heroicons/react/24/solid";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { Fragment } from "react";
|
||||
@@ -10,9 +11,9 @@ export default function MenuProfile({}) {
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="inline-flex items-center ">
|
||||
<Menu.Button className="flex ml-3 text-sm bg-white rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
|
||||
<Menu.Button className="ml-3 flex rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<div className="w-8 h-8">
|
||||
<div className="h-8 w-8">
|
||||
<Image
|
||||
className="rounded-full"
|
||||
src="/img/avatar-placeholder.png"
|
||||
@@ -31,21 +32,19 @@ export default function MenuProfile({}) {
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items
|
||||
static
|
||||
className="absolute right-0 w-48 py-1 mt-2 origin-top-right bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
className="absolute right-0 mt-2 w-48 origin-top-right rounded-sm bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
<button
|
||||
onClick={() => signOut()}
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className={classNames(
|
||||
active ? "bg-gray-100" : "",
|
||||
"block px-4 py-2 text-sm text-ui-gray-dark w-full text-left"
|
||||
)}
|
||||
>
|
||||
active ? "bg-ui-gray-light text-ui-black rounded-sm" : "text-ui-gray-dark",
|
||||
"flex w-full px-4 py-2 text-sm"
|
||||
)}>
|
||||
<ArrowLeftOnRectangleIcon className="text-ui-gray-dark mr-3 h-5 w-5" aria-hidden="true" />
|
||||
Sign Out
|
||||
</button>
|
||||
)}
|
||||
@@ -16,7 +16,7 @@ type MenuStepsProps = {
|
||||
export default function MenuSteps({ steps, currentStep }: MenuStepsProps) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex items-center flex-1 justify-left sm:justify-center">
|
||||
<div className="justify-left flex flex-1 items-center sm:justify-center">
|
||||
<div className="w-full sm:hidden">
|
||||
<label htmlFor="steps" className="sr-only">
|
||||
Select a view
|
||||
@@ -24,13 +24,12 @@ export default function MenuSteps({ steps, currentStep }: MenuStepsProps) {
|
||||
<select
|
||||
id="steps"
|
||||
name="steps"
|
||||
className="block w-full py-2 pl-3 pr-10 text-base rounded-md border-ui-gray-medium focus:outline-none focus:ring-red focus:border-red sm:text-sm"
|
||||
className="border-ui-gray-medium focus:ring-red focus:border-red block w-full rounded-md py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm"
|
||||
defaultValue={steps.find((step) => step.id === currentStep).name}
|
||||
onChange={(e) => {
|
||||
const stepId = e.target.children[e.target.selectedIndex].id;
|
||||
router.push(steps.find((s) => s.id === stepId).href);
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
{steps.map((step) => (
|
||||
<option key={step.name} id={step.id}>
|
||||
{step.name}
|
||||
@@ -39,18 +38,17 @@ export default function MenuSteps({ steps, currentStep }: MenuStepsProps) {
|
||||
</select>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<nav className="flex -mb-px space-x-8" aria-label="steps">
|
||||
<nav className="-mb-px flex space-x-8" aria-label="steps">
|
||||
{steps.map((step) => (
|
||||
<Link key={step.name} href={step.href}>
|
||||
<a
|
||||
className={classNames(
|
||||
step.id === currentStep
|
||||
? "border-red text-red"
|
||||
: "border-transparent text-ui-gray-dark hover:text-ui-gray-dark hover:border-ui-gray-medium",
|
||||
"whitespace-nowrap py-5 px-1 border-b-2 font-medium text-sm"
|
||||
: "text-ui-gray-dark hover:text-ui-gray-dark hover:border-ui-gray-medium border-transparent",
|
||||
"whitespace-nowrap border-b-2 py-5 px-1 text-sm font-medium"
|
||||
)}
|
||||
aria-current={step.id === currentStep ? "page" : undefined}
|
||||
>
|
||||
aria-current={step.id === currentStep ? "page" : undefined}>
|
||||
{step.name}
|
||||
</a>
|
||||
</Link>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlusIcon } from "@heroicons/react/outline";
|
||||
import { PlusIcon } from "@heroicons/react/24/outline";
|
||||
import { useState } from "react";
|
||||
import NewFormModal from "../form/NewFormModal";
|
||||
|
||||
@@ -8,15 +8,14 @@ export default function NewFormNavButton({}) {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="items-center hidden text-sm border-r border-ui-gray-light sm:flex bg-ui-gray-lighter text-ui-gray-dark hover:text-white hover:bg-red-500"
|
||||
onClick={() => setOpenNewFormModal(true)}
|
||||
>
|
||||
<nav className="hidden lg:flex" aria-label="Breadcrumb">
|
||||
className="border-ui-gray-light bg-ui-gray-lighter text-ui-gray-dark hidden items-center border-r text-sm hover:bg-red-500 hover:text-white sm:flex"
|
||||
onClick={() => setOpenNewFormModal(true)}>
|
||||
<nav className="hidden sm:flex" aria-label="Breadcrumb">
|
||||
<ol className="flex items-center space-x-4">
|
||||
<li>
|
||||
<div className="inline-flex items-center px-6 py-2 text-sm font-medium leading-4 bg-transparent border border-transparent hover:text-white focus:outline-none">
|
||||
<div className="inline-flex items-center border border-transparent bg-transparent px-6 py-2 text-sm font-medium leading-4 hover:text-white focus:outline-none">
|
||||
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
Start New Form
|
||||
create form
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
46
apps/web/components/layout/SecondNavBar.tsx
Normal file
46
apps/web/components/layout/SecondNavBar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { classNames } from "../../lib/utils";
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
onClick: () => void;
|
||||
Icon?: React.ElementType;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
navItems: NavItem[];
|
||||
currentItemId?: string;
|
||||
}
|
||||
|
||||
// button component, consuming props
|
||||
const SecondNavBar: React.FC<Props> = ({ navItems, currentItemId }) => {
|
||||
return (
|
||||
<div className="border-ui-gray-light bg-ui-gray-lighter flex flex-shrink-0 items-center justify-center border-b">
|
||||
<nav className="flex space-x-10" aria-label="resultModes">
|
||||
{navItems.map((navItem) => (
|
||||
<button
|
||||
key={navItem.id}
|
||||
className={classNames(
|
||||
`h-16 border-b-2 border-transparent text-xs`,
|
||||
!navItem.disabled &&
|
||||
(navItem.id === currentItemId
|
||||
? "text-red border-red border-b-2"
|
||||
: "text-ui-gray-dark hover:text-red bg-transparent hover:border-gray-300"),
|
||||
navItem.disabled
|
||||
? "text-ui-gray-medium"
|
||||
: "hover:border-red text-ui-gray-dark hover:text-red hover:border-b-2"
|
||||
)}
|
||||
onClick={navItem.onClick}
|
||||
disabled={navItem.disabled}>
|
||||
{navItem.Icon && <navItem.Icon className="mx-auto mb-1 h-6 w-6 stroke-1" />}
|
||||
{navItem.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecondNavBar;
|
||||
21
apps/web/components/layout/WithAuthentication.tsx
Normal file
21
apps/web/components/layout/WithAuthentication.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { signIn, useSession } from "next-auth/react";
|
||||
import Loading from "../Loading";
|
||||
|
||||
const withAuthentication = (Component) =>
|
||||
function WithAuth(props) {
|
||||
const { status } = useSession({
|
||||
required: true,
|
||||
onUnauthenticated() {
|
||||
// The user is not authenticated, handle it here.
|
||||
return signIn();
|
||||
},
|
||||
});
|
||||
|
||||
if (status === "loading") {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
export default withAuthentication;
|
||||
99
apps/web/components/pipelines/AddPipelineModal.tsx
Normal file
99
apps/web/components/pipelines/AddPipelineModal.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
/* This example requires Tailwind CSS v2.0+ */
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPipeline, usePipelines } from "../../lib/pipelines";
|
||||
import Modal from "../Modal";
|
||||
import { webhook, WebhookSettings } from "./webhook";
|
||||
|
||||
const availablePipelines = [webhook];
|
||||
|
||||
const getEmptyPipeline = () => {
|
||||
return { name: "", type: null, events: [], data: {} };
|
||||
};
|
||||
|
||||
export default function AddPipelineModal({ open, setOpen }) {
|
||||
const router = useRouter();
|
||||
const formId = router.query.id.toString();
|
||||
const [typeId, setTypeId] = useState(null);
|
||||
const [pipeline, setPipeline] = useState(getEmptyPipeline());
|
||||
const { pipelines, mutatePipelines } = usePipelines(formId);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeId !== pipeline.type) {
|
||||
setPipeline({ ...pipeline, type: typeId });
|
||||
}
|
||||
}, [typeId, pipeline]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPipeline(getEmptyPipeline());
|
||||
setTypeId(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
const newPipeline = await createPipeline(router.query.id, pipeline);
|
||||
const newPipelines = JSON.parse(JSON.stringify(pipelines));
|
||||
newPipelines.push(newPipeline);
|
||||
mutatePipelines(newPipelines);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
<>
|
||||
{typeId === null ? (
|
||||
<>
|
||||
<h2 className="text-ui-gray-dark mb-6 text-xl font-bold">
|
||||
Please choose a pipeline you want to add
|
||||
</h2>
|
||||
{availablePipelines.map((pipeline) => (
|
||||
<div
|
||||
className="border-ui-gray-light w-full border bg-white shadow sm:rounded"
|
||||
key={pipeline.title}>
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">{pipeline.title}</h3>
|
||||
<div className="mt-2 sm:flex sm:items-start sm:justify-between">
|
||||
<div className="max-w-xl text-sm text-gray-500">
|
||||
<p>{pipeline.description}</p>
|
||||
</div>
|
||||
<div className="mt-5 sm:mt-0 sm:ml-6 sm:flex sm:flex-shrink-0 sm:items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTypeId(pipeline.typeId);
|
||||
}}
|
||||
className="inline-flex items-center rounded-md border border-transparent bg-red-600 px-4 py-2 font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:text-sm">
|
||||
Select
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<form className="w-full space-y-8 divide-y divide-gray-200" onSubmit={handleSubmit}>
|
||||
{typeId === "WEBHOOK" ? <WebhookSettings pipeline={pipeline} setPipeline={setPipeline} /> : null}
|
||||
<div className="pt-5">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-3 inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
50
apps/web/components/pipelines/UpdatePipelineModal.tsx
Normal file
50
apps/web/components/pipelines/UpdatePipelineModal.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { persistPipeline, usePipeline, usePipelines } from "../../lib/pipelines";
|
||||
import Loading from "../Loading";
|
||||
import Modal from "../Modal";
|
||||
import { WebhookSettings } from "./webhook";
|
||||
|
||||
export default function UpdatePipelineModal({ open, setOpen, formId, pipelineId }) {
|
||||
const { pipeline, isLoadingPipeline, mutatePipeline } = usePipeline(formId, pipelineId);
|
||||
const { pipelines, mutatePipelines } = usePipelines(formId);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
await persistPipeline(pipeline);
|
||||
const newPipelines = JSON.parse(JSON.stringify(pipelines));
|
||||
const pipelineIdx = pipelines.findIndex((p) => p.id === pipelineId);
|
||||
if (pipelineIdx > -1) {
|
||||
newPipelines[pipelineIdx] = pipeline;
|
||||
mutatePipelines(newPipelines);
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen}>
|
||||
{isLoadingPipeline ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<form className="w-full space-y-8 divide-y divide-gray-200" onSubmit={handleSubmit}>
|
||||
{pipeline.type === "WEBHOOK" ? (
|
||||
<WebhookSettings pipeline={pipeline} setPipeline={(p) => mutatePipeline(p, false)} />
|
||||
) : null}
|
||||
<div className="pt-5">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="ml-3 inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
|
||||
Update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
157
apps/web/components/pipelines/webhook/SettingsComponent.tsx
Normal file
157
apps/web/components/pipelines/webhook/SettingsComponent.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
const eventTypes = [
|
||||
{
|
||||
id: "PAGE_SUBMISSION",
|
||||
name: "Page Submission",
|
||||
description: "every time a form page is submitted (partial submission)",
|
||||
},
|
||||
];
|
||||
|
||||
export function WebhookSettings({ pipeline, setPipeline }) {
|
||||
const toggleEvent = (eventId) => {
|
||||
const newPipeline = JSON.parse(JSON.stringify(pipeline));
|
||||
const eventIdx = newPipeline.events.indexOf(eventId);
|
||||
if (eventIdx !== -1) {
|
||||
newPipeline.events.splice(eventIdx, 1);
|
||||
} else {
|
||||
newPipeline.events.push(eventId);
|
||||
}
|
||||
setPipeline(newPipeline);
|
||||
};
|
||||
|
||||
const updateField = (field, value, parent = null) => {
|
||||
const newPipeline = JSON.parse(JSON.stringify(pipeline));
|
||||
if (parent) {
|
||||
newPipeline[parent][field] = value;
|
||||
} else {
|
||||
newPipeline[field] = value;
|
||||
}
|
||||
setPipeline(newPipeline);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 divide-y divide-gray-200">
|
||||
<div>
|
||||
<h2 className="text-ui-gray-dark mb-3 text-xl font-bold">Configure Webhook</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Configure your webhook. To learn more about how webhooks work, please check out our docs.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
|
||||
<div className="sm:col-span-4">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Webhook Name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
value={pipeline.name || ""}
|
||||
onChange={(e) => updateField("name", e.target.value)}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-4">
|
||||
<label htmlFor="endpointUrl" className="block text-sm font-medium text-gray-700">
|
||||
Endpoint URL
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="url"
|
||||
pattern="^https:\/\/(.*)"
|
||||
onInvalid={(e: any) =>
|
||||
e.target.setCustomValidity("please provide a valid website address with https")
|
||||
}
|
||||
onInput={(e: any) => e.target.setCustomValidity("")}
|
||||
name="endpointUrl"
|
||||
id="endpointUrl"
|
||||
value={pipeline.data.endpointUrl || ""}
|
||||
onChange={(e) => updateField("endpointUrl", e.target.value, "data")}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500" id="email-description">
|
||||
Your server URL to which the data should be sent (https required)
|
||||
</p>
|
||||
</div>
|
||||
<div className="sm:col-span-4">
|
||||
<label htmlFor="secret" className="block text-sm font-medium text-gray-700">
|
||||
Secret
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="text"
|
||||
name="secret"
|
||||
id="secret"
|
||||
value={pipeline.data.secret || ""}
|
||||
onChange={(e) => updateField("secret", e.target.value, "data")}
|
||||
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500" id="email-description">
|
||||
We sign all event notification payloads with a SHA256 signature using this secret
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium leading-6 text-gray-900">Advanced Settings</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Set up this webhook to fit your needs.</p>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Events</legend>
|
||||
<div className="text-base font-medium text-gray-900" aria-hidden="true">
|
||||
Events
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
{eventTypes.map((eventType) => (
|
||||
<div key={eventType.id}>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-5 items-center">
|
||||
<input
|
||||
id="comments"
|
||||
name="comments"
|
||||
type="checkbox"
|
||||
checked={pipeline.events.includes(eventType.id)}
|
||||
onChange={() => toggleEvent(eventType.id)}
|
||||
className="h-4 w-4 rounded-sm border-gray-300 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm">
|
||||
<label htmlFor="comments" className="font-medium text-gray-700">
|
||||
{eventType.name}
|
||||
</label>
|
||||
<p className="text-gray-500">{eventType.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Conditions</legend>
|
||||
<div className="text-base font-medium text-gray-900" aria-hidden="true">
|
||||
Conditions
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="rounded-sm border border-gray-100 bg-gray-50 px-2 py-5">
|
||||
<p className="flex justify-center text-xs text-gray-600">
|
||||
conditional data piping coming soon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
apps/web/components/pipelines/webhook/handler.ts
Normal file
22
apps/web/components/pipelines/webhook/handler.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import crypto from "crypto";
|
||||
import { ApiEvent } from "../../../lib/types";
|
||||
|
||||
export async function handleWebhook(pipeline, event: ApiEvent) {
|
||||
if (pipeline.data.hasOwnProperty("endpointUrl") && pipeline.data.hasOwnProperty("secret")) {
|
||||
if (event.type === "pageSubmission" && pipeline.events.includes("PAGE_SUBMISSION")) {
|
||||
const webhookData = pipeline.data;
|
||||
const body = { time: Math.floor(Date.now() / 1000), event };
|
||||
fetch(webhookData.endpointUrl.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": `sha256=${crypto
|
||||
.createHmac("sha256", webhookData.secret.toString())
|
||||
.update(JSON.stringify(body))
|
||||
.digest("base64")}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
8
apps/web/components/pipelines/webhook/index.ts
Normal file
8
apps/web/components/pipelines/webhook/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const webhook = {
|
||||
typeId: "WEBHOOK",
|
||||
title: "Webhook",
|
||||
description: "Notify an external endpoint when events happen in your form (e.g. a new submission).",
|
||||
};
|
||||
|
||||
export * from "./SettingsComponent";
|
||||
export * from "./handler";
|
||||
65
apps/web/components/results/AnalyticsCard.tsx
Normal file
65
apps/web/components/results/AnalyticsCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/24/solid";
|
||||
import React from "react";
|
||||
import { classNames } from "../../lib/utils";
|
||||
|
||||
interface Props {
|
||||
value: string | number;
|
||||
label: string;
|
||||
toolTipText: string;
|
||||
trend?: number;
|
||||
smallerText?: boolean;
|
||||
}
|
||||
|
||||
const AnalyticsCard: React.FC<Props> = ({ value, label, toolTipText, trend, smallerText }) => {
|
||||
return (
|
||||
<div className="rounded-md bg-white shadow-md">
|
||||
<div key={label} className="px-4 py-5 sm:p-6">
|
||||
<dt className="has-tooltip inline-flex text-base font-normal text-gray-900">
|
||||
{label}{" "}
|
||||
{toolTipText && (
|
||||
<QuestionMarkCircleIcon className="text-red hover:text-ui-gray-dark ml-1 h-4 w-4" />
|
||||
)}
|
||||
{toolTipText && (
|
||||
<span className="tooltip -mt-6 -ml-8 flex grow rounded bg-gray-600 p-1 px-4 text-center text-xs text-white shadow-lg">
|
||||
{toolTipText}
|
||||
</span>
|
||||
)}
|
||||
</dt>
|
||||
<dd className="mt-1 flex items-baseline justify-between md:block lg:flex">
|
||||
<div
|
||||
className={classNames(
|
||||
smallerText ? "text-lg" : "text-xl",
|
||||
"flex items-baseline text-xl font-semibold text-gray-800"
|
||||
)}>
|
||||
{value}
|
||||
</div>
|
||||
|
||||
{trend && (
|
||||
<div
|
||||
className={classNames(
|
||||
trend >= 0 ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800",
|
||||
"inline-flex items-baseline rounded-full px-2.5 py-0.5 text-sm font-medium md:mt-2 lg:mt-0"
|
||||
)}>
|
||||
{trend >= 0 ? (
|
||||
<ArrowUpIcon
|
||||
className="-ml-1 mr-0.5 h-5 w-5 flex-shrink-0 self-center text-green-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<ArrowDownIcon
|
||||
className="-ml-1 mr-0.5 h-5 w-5 flex-shrink-0 self-center text-red-500"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span className="sr-only">{trend >= 0 ? "Increased" : "Decreased"} by</span>
|
||||
{trend} %
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnalyticsCard;
|
||||
@@ -1,26 +1,20 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { ChevronDownIcon } from "@heroicons/react/solid";
|
||||
import { ChevronDownIcon } from "@heroicons/react/24/solid";
|
||||
import { parseAsync } from "json2csv";
|
||||
import { Fragment } from "react";
|
||||
import { useForm } from "../../lib/forms";
|
||||
import {
|
||||
getSubmission,
|
||||
useSubmissionSessions,
|
||||
} from "../../lib/submissionSessions";
|
||||
import { getSubmission, useSubmissionSessions } from "../../lib/submissionSessions";
|
||||
import { Submission } from "../../lib/types";
|
||||
import { slugify } from "../../lib/utils";
|
||||
import Loading from "../Loading";
|
||||
|
||||
export default function DownloadResponses({ formId }) {
|
||||
const { submissionSessions, isLoadingSubmissionSessions } =
|
||||
useSubmissionSessions(formId);
|
||||
const { submissionSessions, isLoadingSubmissionSessions } = useSubmissionSessions(formId);
|
||||
const { form, isLoadingForm } = useForm(formId);
|
||||
|
||||
const download = async (format: "csv" | "excel") => {
|
||||
// build dict of answers in copy of answerSessions
|
||||
const submissions: Submission[] = submissionSessions.map((s) =>
|
||||
getSubmission(s, form.schema)
|
||||
);
|
||||
const submissions: Submission[] = submissionSessions.map((s) => getSubmission(s, form.schema));
|
||||
// build data fields for csv/excel file
|
||||
const data = [];
|
||||
for (const submission of submissions) {
|
||||
@@ -72,10 +66,7 @@ export default function DownloadResponses({ formId }) {
|
||||
const url = window.URL.createObjectURL(new Blob([blob]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`${slugify(form.name)}.${fileTypes[format].fileExtension}`
|
||||
);
|
||||
link.setAttribute("download", `${slugify(form.name)}.${fileTypes[format].fileExtension}`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode.removeChild(link);
|
||||
@@ -91,12 +82,9 @@ export default function DownloadResponses({ formId }) {
|
||||
return (
|
||||
<Menu as="div" className="relative z-10 inline-block w-full text-left">
|
||||
<div>
|
||||
<Menu.Button className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium text-white bg-gray-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75">
|
||||
<Menu.Button className="inline-flex w-full justify-center bg-gray-400 px-4 py-2 text-sm font-medium text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75">
|
||||
Download
|
||||
<ChevronDownIcon
|
||||
className="w-5 h-5 ml-2 -mr-1 text-white hover:text-gray-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon className="ml-2 -mr-1 h-5 w-5 text-white hover:text-gray-100" aria-hidden="true" />
|
||||
</Menu.Button>
|
||||
</div>
|
||||
<Transition
|
||||
@@ -106,9 +94,8 @@ export default function DownloadResponses({ formId }) {
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items className="absolute right-0 w-56 mt-2 origin-top-right bg-white divide-y divide-gray-100 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
leaveTo="transform opacity-0 scale-95">
|
||||
<Menu.Items className="absolute right-0 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
<div className="px-1 py-1 ">
|
||||
<Menu.Item>
|
||||
{({ active }) => (
|
||||
@@ -116,8 +103,7 @@ export default function DownloadResponses({ formId }) {
|
||||
onClick={() => download("csv")}
|
||||
className={`${
|
||||
active ? "bg-red-500 text-white" : "text-gray-900"
|
||||
} group flex rounded-md items-center w-full px-2 py-2 text-sm`}
|
||||
>
|
||||
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}>
|
||||
Download as CSV
|
||||
</button>
|
||||
)}
|
||||
87
apps/web/components/results/ResultsInsights.tsx
Normal file
87
apps/web/components/results/ResultsInsights.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import Image from "next/image";
|
||||
import { useMemo } from "react";
|
||||
import { getSubmissionAnalytics, useSubmissionSessions } from "../../lib/submissionSessions";
|
||||
import { timeSince } from "../../lib/utils";
|
||||
import Loading from "../Loading";
|
||||
import AnalyticsCard from "./AnalyticsCard";
|
||||
|
||||
export default function ResultsAnalytics({ formId }) {
|
||||
const { submissionSessions, isLoadingSubmissionSessions } = useSubmissionSessions(formId);
|
||||
|
||||
const analytics = useMemo(() => {
|
||||
if (!isLoadingSubmissionSessions) {
|
||||
return getSubmissionAnalytics(submissionSessions);
|
||||
}
|
||||
}, [isLoadingSubmissionSessions, submissionSessions]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (analytics) {
|
||||
return [
|
||||
{
|
||||
id: "totalSubmissions",
|
||||
name: "Total Submissions",
|
||||
stat: analytics.totalSubmissions || "--",
|
||||
trend: undefined,
|
||||
toolTipText: undefined,
|
||||
},
|
||||
{
|
||||
id: "lastSubmission",
|
||||
name: "Last Submission",
|
||||
stat: analytics.lastSubmissionAt ? timeSince(analytics.lastSubmissionAt) : "--",
|
||||
smallerText: true,
|
||||
toolTipText: undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
}, [analytics]);
|
||||
|
||||
if (!stats || !analytics) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-8">
|
||||
<h2 className="text-ui-gray-dark text-xl font-bold">Analytics</h2>
|
||||
<div>
|
||||
<dl className="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{stats.map((item) => (
|
||||
<AnalyticsCard
|
||||
key={item.id}
|
||||
value={item.stat}
|
||||
label={item.name}
|
||||
toolTipText={item.toolTipText}
|
||||
trend={item.trend}
|
||||
smallerText={item.smallerText}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<h2 className="text-ui-gray-dark mt-16 text-xl font-bold">Optimize Form</h2>
|
||||
<div className="ml-2 rounded-sm bg-green-50 px-3 py-2 text-xs text-green-800">
|
||||
<p>coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 grid grid-cols-2 gap-10">
|
||||
<div className="rounded-md bg-white p-5 shadow-md">
|
||||
<Image
|
||||
src="/../../img/drop-offs-v1.svg"
|
||||
alt="drop-off"
|
||||
layout="responsive"
|
||||
width={500}
|
||||
height={273}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-md bg-white p-5 shadow-md">
|
||||
<Image
|
||||
src="/../../img/a-b-test-v1.svg"
|
||||
alt="drop-off"
|
||||
layout="responsive"
|
||||
width={500}
|
||||
height={273}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
apps/web/components/results/ResultsResponses.tsx
Normal file
191
apps/web/components/results/ResultsResponses.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import { CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { getEventName } from "../../lib/events";
|
||||
import { useSubmissionSessions } from "../../lib/submissionSessions";
|
||||
import { SubmissionSession } from "../../lib/types";
|
||||
import { convertDateTimeString, convertTimeString } from "../../lib/utils";
|
||||
import SubmissionDisplay from "./SubmissionDisplay";
|
||||
import DownloadResponses from "./DownloadResponses";
|
||||
import Loading from "../Loading";
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
function classNames(...classes) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type ResultsResponseProps = {
|
||||
formId: string;
|
||||
};
|
||||
|
||||
export default function ResultsResponses({ formId }: ResultsResponseProps) {
|
||||
const { submissionSessions, isLoadingSubmissionSessions, mutateSubmissionSessions } =
|
||||
useSubmissionSessions(formId);
|
||||
const [activeSubmissionSession, setActiveSubmissionSession] = useState<SubmissionSession | null>(null);
|
||||
|
||||
const handleDelete = async (submissionSession: SubmissionSession) => {
|
||||
try {
|
||||
await fetch(`/api/forms/${formId}/submissionSessions/${submissionSession.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
await mutateSubmissionSessions();
|
||||
setActiveSubmissionSession(null);
|
||||
toast("Successfully deleted");
|
||||
} catch (error) {
|
||||
toast(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoadingSubmissionSessions && submissionSessions.length > 0) {
|
||||
setActiveSubmissionSession(submissionSessions[0]);
|
||||
}
|
||||
}, [isLoadingSubmissionSessions, submissionSessions]);
|
||||
|
||||
if (isLoadingSubmissionSessions) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-screen mx-auto flex h-full w-full flex-1 flex-col overflow-visible">
|
||||
<div className="relative z-0 flex h-full flex-1 overflow-visible">
|
||||
<main className="relative z-0 mb-32 flex-1 overflow-y-auto focus:outline-none xl:order-last">
|
||||
<div className="overflow-visible sm:rounded-lg">
|
||||
{!activeSubmissionSession ? (
|
||||
<button
|
||||
type="button"
|
||||
className="relative mx-auto mt-8 block w-96 rounded-lg border-2 border-dashed border-gray-300 p-12 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
|
||||
<span className="mt-2 block text-sm font-medium text-gray-500">
|
||||
Select a response on the left to see the details here
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="bg-white px-4 py-5 shadow sm:px-12 sm:pb-4 sm:pt-12">
|
||||
<div className="grid grid-cols-2 gap-8 divide-x">
|
||||
<div className="flow-root">
|
||||
<h1 className="mb-8 text-gray-700">
|
||||
{convertDateTimeString(activeSubmissionSession.createdAt)}
|
||||
</h1>
|
||||
<SubmissionDisplay
|
||||
key={activeSubmissionSession.id}
|
||||
submissionSession={activeSubmissionSession}
|
||||
formId={formId}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden pl-10 md:flow-root">
|
||||
<h1 className="mb-8 text-gray-700">Session Activity</h1>
|
||||
<ul role="list" className="-mb-8">
|
||||
{activeSubmissionSession.events.map((event, eventIdx) => (
|
||||
<li key={event.id}>
|
||||
<div className="relative pb-8">
|
||||
{eventIdx !== activeSubmissionSession.events.length - 1 ? (
|
||||
<span
|
||||
className="bg-ui-gray-light absolute top-4 left-4 -ml-px h-full w-0.5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative flex space-x-3">
|
||||
<div>
|
||||
<span
|
||||
className={classNames(
|
||||
"bg-red-200",
|
||||
"flex h-8 w-8 items-center justify-center rounded-full ring-8 ring-white"
|
||||
)}>
|
||||
<CheckIcon className="h-5 w-5 text-white" aria-hidden="true" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap justify-between gap-4 pt-1.5">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{getEventName(event.type)}
|
||||
{/* <span className="font-medium text-gray-900">
|
||||
{event.data.pageName || ""}
|
||||
</span> */}
|
||||
</p>
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-right text-sm text-gray-500">
|
||||
<time dateTime={event.createdAt}>
|
||||
{convertTimeString(event.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<button
|
||||
className="flex w-full items-center justify-center gap-2 border border-transparent bg-gray-300 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-500 focus:outline-none"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm("Are you sure you want to delete this submission? It will be gone forever!")
|
||||
) {
|
||||
handleDelete(activeSubmissionSession);
|
||||
}
|
||||
}}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
Delete Submission
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<aside className="border-ui-gray-light order-first flex h-full flex-1 flex-shrink-0 flex-col border-r md:w-96 md:flex-none">
|
||||
<DownloadResponses formId={formId} />
|
||||
<div className="pt-4 pb-2">
|
||||
<h2 className="px-5 text-lg font-medium text-gray-900">Responses</h2>
|
||||
</div>
|
||||
{submissionSessions.length === 0 ? (
|
||||
<p className="mt-3 px-5 text-sm text-gray-500">No responses yet</p>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={activeSubmissionSession}
|
||||
onChange={setActiveSubmissionSession}
|
||||
className="mb-32 min-h-0 flex-1 overflow-y-auto shadow-inner"
|
||||
as="div">
|
||||
<div className="relative">
|
||||
<ul className="divide-ui-gray-light relative z-0 divide-y">
|
||||
{submissionSessions.map((submissionSession) => (
|
||||
<RadioGroup.Option
|
||||
key={submissionSession.id}
|
||||
value={submissionSession}
|
||||
className={({ checked }) =>
|
||||
classNames(
|
||||
checked ? "bg-gray-100" : "",
|
||||
"relative flex items-center space-x-3 px-6 py-5 "
|
||||
)
|
||||
}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
onClick={() => setActiveSubmissionSession(submissionSession)}
|
||||
className="w-full text-left focus:outline-none">
|
||||
{/* Extend touch target to entire panel */}
|
||||
<span className="absolute inset-0" aria-hidden="true" />
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{convertDateTimeString(submissionSession.createdAt)}
|
||||
</p>
|
||||
<p className="truncate text-sm text-gray-500">
|
||||
{submissionSession.events.length} events
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</RadioGroup.Option>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,14 +10,14 @@ import { timeSince } from "../../lib/utils";
|
||||
import AnalyticsCard from "./AnalyticsCard";
|
||||
import Loading from "../Loading";
|
||||
import TextResults from "./summary/TextResults";
|
||||
import ChoiceResults from "./summary/ChoiceResults";
|
||||
|
||||
export default function ResultsSummary({ formId }) {
|
||||
const { submissionSessions, isLoadingSubmissionSessions } =
|
||||
useSubmissionSessions(formId);
|
||||
const { submissionSessions, isLoadingSubmissionSessions } = useSubmissionSessions(formId);
|
||||
|
||||
const { form, isLoadingForm } = useForm(formId);
|
||||
|
||||
const analytics = useMemo(() => {
|
||||
const insights = useMemo(() => {
|
||||
if (!isLoadingSubmissionSessions) {
|
||||
return getSubmissionAnalytics(submissionSessions);
|
||||
}
|
||||
@@ -30,41 +30,34 @@ export default function ResultsSummary({ formId }) {
|
||||
}, [isLoadingSubmissionSessions, submissionSessions, isLoadingForm, form]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (analytics) {
|
||||
if (insights) {
|
||||
return [
|
||||
{
|
||||
id: "uniqueUsers",
|
||||
name: "Unique Users",
|
||||
stat: analytics.uniqueUsers || "--",
|
||||
toolTipText: "Tracked without cookies using fingerprinting technique",
|
||||
trend: undefined,
|
||||
},
|
||||
{
|
||||
id: "totalSubmissions",
|
||||
name: "Total Submissions",
|
||||
stat: analytics.totalSubmissions || "--",
|
||||
stat: insights.totalSubmissions || "--",
|
||||
trend: undefined,
|
||||
toolTipText: undefined,
|
||||
},
|
||||
{
|
||||
id: "lastSubmission",
|
||||
name: "Last Submission",
|
||||
stat: timeSince(analytics.lastSubmissionAt) || "--",
|
||||
stat: insights.lastSubmissionAt ? timeSince(insights.lastSubmissionAt) : "--",
|
||||
smallerText: true,
|
||||
toolTipText: undefined,
|
||||
},
|
||||
];
|
||||
}
|
||||
}, [analytics]);
|
||||
}, [insights]);
|
||||
|
||||
if (!summary || !analytics) {
|
||||
if (!summary || !insights) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="mt-8 text-xl font-bold text-ui-gray-dark">
|
||||
Responses Overview
|
||||
</h2>
|
||||
<dl className="grid grid-cols-1 gap-5 mt-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<h2 className="text-ui-gray-dark mt-8 text-xl font-bold">Responses Overview</h2>
|
||||
<dl className="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
{stats.map((item) => (
|
||||
<AnalyticsCard
|
||||
key={item.id}
|
||||
@@ -83,8 +76,10 @@ export default function ResultsSummary({ formId }) {
|
||||
page.type === "form" && (
|
||||
<div key={page.name}>
|
||||
{page.elements.map((element) =>
|
||||
element.type === "text" || element.type === "textarea" ? (
|
||||
["email", "number", "phone", "text", "textarea", "website"].includes(element.type) ? (
|
||||
<TextResults element={element} />
|
||||
) : ["checkbox", "radio"].includes(element.type) ? (
|
||||
<ChoiceResults element={element} />
|
||||
) : null
|
||||
)}
|
||||
</div>
|
||||
@@ -44,22 +44,18 @@ export default function Submission({ formId, submissionSession }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow sm:rounded-lg max-w-">
|
||||
<div className="max-w- bg-white shadow sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="text-ui-gray-dark-600">
|
||||
<p className="text-sm">
|
||||
{convertDateTimeString(submission.createdAt)}
|
||||
</p>
|
||||
<p className="text-sm">{convertDateTimeString(submission.createdAt)}</p>
|
||||
{submission.pages.map((page) => (
|
||||
<div key={page.name}>
|
||||
{page.elements?.map(
|
||||
(element) =>
|
||||
element.type !== "submit" && (
|
||||
<div key={element.name}>
|
||||
<p className="font-semibold text-red">{element.label}</p>
|
||||
<p className="font-normal">
|
||||
{element.value || "[not provided]"}
|
||||
</p>
|
||||
<p className="text-red font-semibold">{element.label}</p>
|
||||
<p className="font-normal">{element.value || "[not provided]"}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
@@ -19,23 +19,20 @@ export default function SubmissionDisplay({ formId, submissionSession }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flow-root mt-6">
|
||||
<ul role="list" className="-my-5 divide-y divide-ui-gray-light">
|
||||
<div className="flow-root">
|
||||
<ul role="list" className="divide-ui-gray-light divide-y">
|
||||
{submission.pages.map((page) =>
|
||||
page.elements?.map(
|
||||
(element) =>
|
||||
element.type !== "submit" && (
|
||||
<li key={element.name} className="py-5">
|
||||
<p className="text-sm font-semibold text-gray-800">
|
||||
{element.label}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-gray-800">{element.label}</p>
|
||||
|
||||
<p
|
||||
className={classNames(
|
||||
element.value ? "text-gray-600" : "text-gray-400",
|
||||
"mt-1 text-sm text-gray-600 line-clamp-2"
|
||||
)}
|
||||
>
|
||||
"whitespace-pre-line pt-1 text-sm text-gray-600"
|
||||
)}>
|
||||
{element.value || "[not provided]"}
|
||||
</p>
|
||||
</li>
|
||||
70
apps/web/components/results/summary/BaseResults.tsx
Normal file
70
apps/web/components/results/summary/BaseResults.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
AtSymbolIcon,
|
||||
CheckCircleIcon,
|
||||
GlobeAltIcon,
|
||||
HashtagIcon,
|
||||
Bars4Icon,
|
||||
PhoneIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { IoMdRadioButtonOn } from "react-icons/io";
|
||||
import { classNames } from "../../../lib/utils";
|
||||
|
||||
export const elementTypes = [
|
||||
{
|
||||
type: "email",
|
||||
icon: AtSymbolIcon,
|
||||
},
|
||||
{
|
||||
type: "number",
|
||||
icon: HashtagIcon,
|
||||
},
|
||||
{
|
||||
type: "phone",
|
||||
icon: PhoneIcon,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
icon: Bars4Icon,
|
||||
},
|
||||
{
|
||||
type: "textarea",
|
||||
icon: Bars4Icon,
|
||||
},
|
||||
{
|
||||
type: "checkbox",
|
||||
icon: CheckCircleIcon,
|
||||
},
|
||||
{
|
||||
type: "radio",
|
||||
icon: IoMdRadioButtonOn,
|
||||
},
|
||||
{
|
||||
type: "website",
|
||||
icon: GlobeAltIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export const getElementTypeIcon = (type) => {
|
||||
const elementType = elementTypes.find((e) => e.type === type);
|
||||
return elementType ? (
|
||||
<span className={classNames(`text-white`, `bg-red-500`, "inline-flex rounded-lg p-3 ring-4 ring-white")}>
|
||||
<elementType.icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default function BaseResults({ element, children }) {
|
||||
return (
|
||||
<div className="my-8 overflow-hidden rounded-lg bg-white shadow">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">{getElementTypeIcon(element.type)}</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="text-md font-medium leading-6 text-gray-900">{element.label}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
apps/web/components/results/summary/ChoiceResults.tsx
Normal file
47
apps/web/components/results/summary/ChoiceResults.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import "chart.js/auto";
|
||||
import { Chart } from "react-chartjs-2";
|
||||
import BaseResults from "./BaseResults";
|
||||
|
||||
export default function ChoiceResults({ element }) {
|
||||
const data = {
|
||||
//labels: element.data.options,
|
||||
labels: element.options.map((o) => o.label),
|
||||
datasets: [
|
||||
{
|
||||
//data: getDataset(element, elementAnswers),
|
||||
data: element.options.map((o) => o.summary || 0),
|
||||
backgroundColor: ["rgba(245, 59, 87, 0.7)"],
|
||||
borderColor: ["rgba(245, 59, 87, 1)"],
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options: any = {
|
||||
indexAxis: "y",
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
yAxis: [
|
||||
{
|
||||
ticks: {
|
||||
min: 1,
|
||||
precision: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseResults element={element}>
|
||||
<div className="my-4 mt-6 flow-root px-8 text-center">
|
||||
<Chart type="bar" data={data} options={options} height={75} />
|
||||
</div>
|
||||
</BaseResults>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import BaseResults from "./BaseResults";
|
||||
export default function TextResults({ element }) {
|
||||
return (
|
||||
<BaseResults element={element}>
|
||||
<div className="flow-root px-8 my-4 mt-6 overflow-y-scroll text-center max-h-64">
|
||||
<ul className="-my-5 divide-y divide-ui-gray-light">
|
||||
{element.summary.map((answer) => (
|
||||
<li key={answer} className="py-8">
|
||||
<div className="my-4 mt-6 flow-root h-44 max-h-64 overflow-y-scroll px-8 text-center">
|
||||
<ul className="divide-ui-gray-light -my-5 divide-y">
|
||||
{element?.summary?.map((answer) => (
|
||||
<li key={answer} className="py-4">
|
||||
<div className="relative focus-within:ring-2 focus-within:ring-indigo-500">
|
||||
<h3 className="text-sm text-gray-700">
|
||||
{/* Extend touch target to entire panel */}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { prisma } from "./prisma";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const formHasOwnership = async (session, formId) => {
|
||||
try {
|
||||
83
apps/web/lib/apiEvents.ts
Normal file
83
apps/web/lib/apiEvents.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { handleWebhook } from "../components/pipelines/webhook";
|
||||
import { capturePosthogEvent } from "./posthog";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendTelemetry } from "./telemetry";
|
||||
import { ApiEvent } from "./types";
|
||||
|
||||
type validationError = {
|
||||
status: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const validateEvents = (events: ApiEvent[]): validationError | undefined => {
|
||||
if (!Array.isArray(events)) {
|
||||
return { status: 400, message: `"events" needs to be a list` };
|
||||
}
|
||||
for (const event of events) {
|
||||
if (
|
||||
!["createSubmissionSession", "pageSubmission", "submissionCompleted", "updateSchema"].includes(
|
||||
event.type
|
||||
)
|
||||
) {
|
||||
return {
|
||||
status: 400,
|
||||
message: `event type ${event.type} is not suppported`,
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
export const processApiEvent = async (event: ApiEvent, formId) => {
|
||||
// save submission
|
||||
if (event.type === "pageSubmission") {
|
||||
const data = event.data;
|
||||
await prisma.sessionEvent.create({
|
||||
data: {
|
||||
type: "pageSubmission",
|
||||
data: {
|
||||
pageName: data.pageName,
|
||||
submission: data.submission,
|
||||
},
|
||||
submissionSession: { connect: { id: data.submissionSessionId } },
|
||||
},
|
||||
});
|
||||
const form = await prisma.form.findUnique({
|
||||
where: {
|
||||
id: formId,
|
||||
},
|
||||
});
|
||||
capturePosthogEvent(form.ownerId, "pageSubmission received", {
|
||||
formId,
|
||||
formType: form.formType,
|
||||
});
|
||||
sendTelemetry("pageSubmission received");
|
||||
} else if (event.type === "submissionCompleted") {
|
||||
// TODO
|
||||
} else if (event.type === "updateSchema") {
|
||||
const data = { schema: event.data, updatedAt: new Date() };
|
||||
await prisma.form.update({
|
||||
where: { id: formId },
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
throw Error(`apiEvents: unsupported event type in event ${JSON.stringify(event)}`);
|
||||
}
|
||||
// handle integrations
|
||||
const pipelines = await prisma.pipeline.findMany({
|
||||
where: {
|
||||
form: { id: formId },
|
||||
enabled: true,
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
});
|
||||
for (const pipeline of pipelines) {
|
||||
if (pipeline.type === "WEBHOOK") {
|
||||
handleWebhook(pipeline, event);
|
||||
}
|
||||
}
|
||||
};
|
||||
28
apps/web/lib/auth.ts
Normal file
28
apps/web/lib/auth.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
const hashedPassword = await hash(password, 12);
|
||||
return hashedPassword;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hashedPassword: string) {
|
||||
const isValid = await compare(password, hashedPassword);
|
||||
return isValid;
|
||||
}
|
||||
export function requireAuthentication(gssp) {
|
||||
return async (context) => {
|
||||
const { req, resolvedUrl } = context;
|
||||
const token = req.cookies.userToken;
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: `/auth/signin?callbackUrl=${encodeURIComponent(resolvedUrl)}`,
|
||||
statusCode: 302,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return await gssp(context); // Continue on to call `getServerSideProps` logic
|
||||
};
|
||||
}
|
||||
77
apps/web/lib/email.ts
Normal file
77
apps/web/lib/email.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { createToken } from "./jwt";
|
||||
const nodemailer = require("nodemailer");
|
||||
|
||||
interface sendEmailData {
|
||||
to: string;
|
||||
subject: string;
|
||||
text?: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export const sendEmail = async (emailData: sendEmailData) => {
|
||||
let transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: process.env.SMTP_PORT,
|
||||
secure: process.env.SMTP_SECURE_ENABLED === "1", // true for 465, false for other ports
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
// logger: true,
|
||||
// debug: true,
|
||||
});
|
||||
const emailDefaults = {
|
||||
from: `snoopForms <${process.env.MAIL_FROM || "noreply@snoopforms.com"}>`,
|
||||
};
|
||||
await transporter.sendMail({ ...emailDefaults, ...emailData });
|
||||
};
|
||||
|
||||
export const sendVerificationEmail = async (user) => {
|
||||
const token = createToken(user.id, user.email, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${process.env.NEXTAUTH_URL}/auth/verify?token=${encodeURIComponent(token)}`;
|
||||
const verificationRequestLink = `${
|
||||
process.env.NEXTAUTH_URL
|
||||
}/auth/verification-requested?email=${encodeURIComponent(user.email)}`;
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Welcome to snoopForms",
|
||||
html: `Welcome to snoopForms!<br/><br/>To verify your email address and start using snoopForms please click this link:<br/>
|
||||
<a href="${verifyLink}">${verifyLink}</a><br/>
|
||||
<br/>
|
||||
The link is valid for one day. If it has expired please request a new token here:<br/>
|
||||
<a href="${verificationRequestLink}">${verificationRequestLink}</a><br/>
|
||||
<br/>
|
||||
Your snoopForms Team`,
|
||||
});
|
||||
};
|
||||
|
||||
export const sendForgotPasswordEmail = async (user) => {
|
||||
const token = createToken(user.id, user.email, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${process.env.NEXTAUTH_URL}/auth/reset-password?token=${encodeURIComponent(token)}`;
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Reset your snoopForms password",
|
||||
html: `You have requested a link to change your password. You can do this through the link below:<br/>
|
||||
<a href="${verifyLink}">${verifyLink}</a><br/>
|
||||
<br/>
|
||||
The link is valid for 24 hours. If you didn't request this, please ignore this email.<br/>
|
||||
<br/>
|
||||
Your password won't change until you access the link above and create a new one.<br/>
|
||||
<br/>
|
||||
Your snoopForms Team`,
|
||||
});
|
||||
};
|
||||
|
||||
export const sendPasswordResetNotifyEmail = async (user) => {
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: "Your snoopForms password has been changed",
|
||||
html: `We're contacting you to notify you that your password has been changed.<br/>
|
||||
<br/>
|
||||
Your snoopForms Team`,
|
||||
});
|
||||
};
|
||||
@@ -12,7 +12,7 @@ export const useForms = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const useForm = (id) => {
|
||||
export const useForm = (id: string) => {
|
||||
const { data, error, mutate } = useSWR(`/api/forms/${id}/`, fetcher);
|
||||
|
||||
return {
|
||||
@@ -56,15 +56,7 @@ export const getFormElementFieldSetter = (
|
||||
elementId: string
|
||||
) => {
|
||||
return (input, field, parentField = "") =>
|
||||
setFormElementField(
|
||||
form,
|
||||
mutateForm,
|
||||
pageId,
|
||||
elementId,
|
||||
input,
|
||||
field,
|
||||
parentField
|
||||
);
|
||||
setFormElementField(form, mutateForm, pageId, elementId, input, field, parentField);
|
||||
};
|
||||
|
||||
export const setFormElementField = (
|
||||
@@ -77,17 +69,12 @@ export const setFormElementField = (
|
||||
parentField: string = ""
|
||||
) => {
|
||||
const updatedForm = JSON.parse(JSON.stringify(form));
|
||||
const elementIdx = getFormPage(updatedForm, pageId).elements.findIndex(
|
||||
(e) => e.id === elementId
|
||||
);
|
||||
const elementIdx = getFormPage(updatedForm, pageId).elements.findIndex((e) => e.id === elementId);
|
||||
if (typeof elementIdx === "undefined") {
|
||||
throw Error(
|
||||
`setFormElementField: unable to find element with id ${elementId}`
|
||||
);
|
||||
throw Error(`setFormElementField: unable to find element with id ${elementId}`);
|
||||
}
|
||||
if (parentField !== "") {
|
||||
getFormPage(updatedForm, pageId).elements[elementIdx][parentField][field] =
|
||||
input;
|
||||
getFormPage(updatedForm, pageId).elements[elementIdx][parentField][field] = input;
|
||||
} else {
|
||||
getFormPage(updatedForm, pageId).elements[elementIdx][field] = input;
|
||||
}
|
||||
24
apps/web/lib/jwt.ts
Normal file
24
apps/web/lib/jwt.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export function createToken(userId, userEmail, options = {}) {
|
||||
return jwt.sign({ id: userId }, process.env.NEXTAUTH_SECRET + userEmail, options);
|
||||
}
|
||||
|
||||
export async function verifyToken(token, userEmail = "") {
|
||||
if (!userEmail) {
|
||||
const { id } = jwt.decode(token);
|
||||
|
||||
const foundUser = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!foundUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
userEmail = foundUser.email;
|
||||
}
|
||||
|
||||
return jwt.verify(token, process.env.NEXTAUTH_SECRET + userEmail);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DocumentSearchIcon, TerminalIcon } from "@heroicons/react/outline";
|
||||
import { DocumentMagnifyingGlassIcon, CommandLineIcon } from "@heroicons/react/24/outline";
|
||||
import { useRouter } from "next/router";
|
||||
import { FaReact } from "react-icons/fa";
|
||||
|
||||
@@ -10,7 +10,7 @@ export const useCodeSecondNavigation = (formId) => {
|
||||
onClick: () => {
|
||||
router.push(`/forms/${formId}/form`);
|
||||
},
|
||||
Icon: TerminalIcon,
|
||||
Icon: CommandLineIcon,
|
||||
label: "Form ID",
|
||||
},
|
||||
{
|
||||
@@ -31,7 +31,7 @@ export const useCodeSecondNavigation = (formId) => {
|
||||
{
|
||||
id: "vueJs",
|
||||
onClick: () => {},
|
||||
Icon: TerminalIcon,
|
||||
Icon: CommandLineIcon,
|
||||
label: "VueJs",
|
||||
disabled: true,
|
||||
},
|
||||
@@ -40,7 +40,7 @@ export const useCodeSecondNavigation = (formId) => {
|
||||
onClick: () => {
|
||||
window.open("https://docs.snoopforms.com", "_ blank");
|
||||
},
|
||||
Icon: DocumentSearchIcon,
|
||||
Icon: DocumentMagnifyingGlassIcon,
|
||||
label: "Docs",
|
||||
},
|
||||
];
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
ChartBarIcon,
|
||||
InboxIcon,
|
||||
TrendingUpIcon,
|
||||
} from "@heroicons/react/outline";
|
||||
import { ChartBarIcon, InboxIcon, ArrowTrendingUpIcon } from "@heroicons/react/24/outline";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
export const useFormResultsSecondNavigation = (formId) => {
|
||||
@@ -25,12 +21,12 @@ export const useFormResultsSecondNavigation = (formId) => {
|
||||
label: "Responses",
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
id: "insights",
|
||||
onClick: () => {
|
||||
router.push(`/forms/${formId}/results/analytics`);
|
||||
router.push(`/forms/${formId}/results/insights`);
|
||||
},
|
||||
Icon: TrendingUpIcon,
|
||||
label: "Analytics",
|
||||
Icon: ArrowTrendingUpIcon,
|
||||
label: "Insights",
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -2,10 +2,7 @@ import useSWR from "swr";
|
||||
import { fetcher } from "./utils";
|
||||
|
||||
export const useNoCodeForm = (formId) => {
|
||||
const { data, error, mutate } = useSWR(
|
||||
`/api/forms/${formId}/nocodeform`,
|
||||
fetcher
|
||||
);
|
||||
const { data, error, mutate } = useSWR(`/api/forms/${formId}/nocodeform`, fetcher);
|
||||
|
||||
return {
|
||||
noCodeForm: data,
|
||||
@@ -16,10 +13,7 @@ export const useNoCodeForm = (formId) => {
|
||||
};
|
||||
|
||||
export const useNoCodeFormPublic = (formId) => {
|
||||
const { data, error, mutate } = useSWR(
|
||||
`/api/public/forms/${formId}/nocodeform`,
|
||||
fetcher
|
||||
);
|
||||
const { data, error, mutate } = useSWR(`/api/public/forms/${formId}/nocodeform`, fetcher);
|
||||
|
||||
return {
|
||||
noCodeForm: data,
|
||||
@@ -39,9 +33,7 @@ export const createNoCodeForm = async (formId) => {
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw Error(
|
||||
`createNoCodeForm: unable to create noCodeForm: ${error.message}`
|
||||
);
|
||||
throw Error(`createNoCodeForm: unable to create noCodeForm: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
62
apps/web/lib/pipelines.ts
Normal file
62
apps/web/lib/pipelines.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "./utils";
|
||||
|
||||
export const usePipeline = (formId: string, pipelineId: string) => {
|
||||
const { data, error, mutate } = useSWR(() => `/api/forms/${formId}/pipelines/${pipelineId}`, fetcher);
|
||||
|
||||
return {
|
||||
pipeline: data,
|
||||
isLoadingPipeline: !error && !data,
|
||||
isErrorPipeline: error,
|
||||
mutatePipeline: mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const usePipelines = (formId: string) => {
|
||||
const { data, error, mutate } = useSWR(() => `/api/forms/${formId}/pipelines`, fetcher);
|
||||
|
||||
return {
|
||||
pipelines: data,
|
||||
isLoadingPipelines: !error && !data,
|
||||
isErrorPipelines: error,
|
||||
mutatePipelines: mutate,
|
||||
};
|
||||
};
|
||||
|
||||
export const persistPipeline = async (pipeline) => {
|
||||
try {
|
||||
await fetch(`/api/forms/${pipeline.formId}/pipelines/${pipeline.id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pipeline),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const createPipeline = async (formId, pipeline) => {
|
||||
try {
|
||||
const res = await fetch(`/api/forms/${formId}/pipelines`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(pipeline),
|
||||
});
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw Error(`create Pipeline: unable to create pipeline: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const deletePipeline = async (formId, pipelineId) => {
|
||||
try {
|
||||
const res = await fetch(`/api/forms/${formId}/pipelines/${pipelineId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw Error(`delete Pipeline: unable to delete pipeline: ${error.message}`);
|
||||
}
|
||||
};
|
||||
27
apps/web/lib/posthog.ts
Normal file
27
apps/web/lib/posthog.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { hashString } from "./utils";
|
||||
|
||||
const enabled =
|
||||
process.env.NODE_ENV === "production" && process.env.POSTHOG_API_HOST && process.env.POSTHOG_API_KEY;
|
||||
|
||||
export const capturePosthogEvent = async (userId, eventName, properties = {}) => {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fetch(`${process.env.POSTHOG_API_HOST}/capture/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: process.env.POSTHOG_API_KEY,
|
||||
event: eventName,
|
||||
properties: {
|
||||
distinct_id: hashString(userId.toString()),
|
||||
...properties,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("error sending posthog event:", error);
|
||||
}
|
||||
};
|
||||
@@ -3,10 +3,7 @@ import { Schema, SubmissionSession, SubmissionSummary } from "./types";
|
||||
import { fetcher } from "./utils";
|
||||
|
||||
export const useSubmissionSessions = (formId: string) => {
|
||||
const { data, error, mutate } = useSWR(
|
||||
() => `/api/forms/${formId}/submissionSessions`,
|
||||
fetcher
|
||||
);
|
||||
const { data, error, mutate } = useSWR(() => `/api/forms/${formId}/submissionSessions`, fetcher);
|
||||
|
||||
return {
|
||||
submissionSessions: data,
|
||||
@@ -32,9 +29,7 @@ export const getSubmission = (submissionSession, schema) => {
|
||||
const submissionPage = {
|
||||
name: page.name,
|
||||
type: page.type,
|
||||
elements: page.elements
|
||||
? JSON.parse(JSON.stringify(page.elements))
|
||||
: [],
|
||||
elements: page.elements ? JSON.parse(JSON.stringify(page.elements)) : [],
|
||||
};
|
||||
// search for elements in schema pages of type "form" and fill their value into the submission
|
||||
if (page.type === "form") {
|
||||
@@ -44,9 +39,8 @@ export const getSubmission = (submissionSession, schema) => {
|
||||
if (typeof pageSubmission !== "undefined") {
|
||||
for (const [elementIdx, element] of page.elements.entries()) {
|
||||
if (element.type !== "submit") {
|
||||
if (element.name in pageSubmission.data?.submission) {
|
||||
submissionPage.elements[elementIdx].value =
|
||||
pageSubmission.data.submission[element.name];
|
||||
if ("submission" in pageSubmission.data && element.name in pageSubmission.data?.submission) {
|
||||
submissionPage.elements[elementIdx].value = pageSubmission.data.submission[element.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,73 +52,59 @@ export const getSubmission = (submissionSession, schema) => {
|
||||
return submission;
|
||||
};
|
||||
|
||||
export const getSubmissionAnalytics = (
|
||||
submissionSessions: SubmissionSession[]
|
||||
) => {
|
||||
const uniqueUsers = [];
|
||||
export const getSubmissionAnalytics = (submissionSessions: SubmissionSession[]) => {
|
||||
let totalSubmissions = 0;
|
||||
let lastSubmissionAt = null;
|
||||
for (const submissionSession of submissionSessions) {
|
||||
// collect unique users
|
||||
if (!uniqueUsers.includes(submissionSession.userFingerprint)) {
|
||||
uniqueUsers.push(submissionSession.userFingerprint);
|
||||
}
|
||||
if (submissionSession.events.length > 0) {
|
||||
totalSubmissions += 1;
|
||||
const lastSubmission =
|
||||
submissionSession.events[submissionSession.events.length - 1];
|
||||
const lastSubmission = submissionSession.events[submissionSession.events.length - 1];
|
||||
if (!lastSubmissionAt) {
|
||||
lastSubmissionAt = lastSubmission.createdAt;
|
||||
} else if (
|
||||
Date.parse(lastSubmission.createdAt) > Date.parse(lastSubmissionAt)
|
||||
) {
|
||||
} else if (Date.parse(lastSubmission.createdAt) > Date.parse(lastSubmissionAt)) {
|
||||
lastSubmissionAt = lastSubmission.createdAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
lastSubmissionAt,
|
||||
uniqueUsers: uniqueUsers.length,
|
||||
totalSubmissions: totalSubmissions,
|
||||
};
|
||||
};
|
||||
|
||||
export const getSubmissionSummary = (
|
||||
submissionSessions: SubmissionSession[],
|
||||
schema: Schema
|
||||
) => {
|
||||
export const getSubmissionSummary = (submissionSessions: SubmissionSession[], schema: Schema) => {
|
||||
if (!schema) return;
|
||||
const summary: SubmissionSummary = JSON.parse(JSON.stringify(schema));
|
||||
// iterate through SubmissionSessions and add values to summary
|
||||
for (const submissionSession of submissionSessions) {
|
||||
for (const submissionEvent of submissionSession.events) {
|
||||
if (submissionEvent.type === "pageSubmission") {
|
||||
const summaryPage = summary.pages.find(
|
||||
(p) => p.name === submissionEvent.data.pageName
|
||||
);
|
||||
if (summaryPage.type === "form") {
|
||||
for (const [elementName, elementValue] of Object.entries(
|
||||
submissionEvent.data.submission
|
||||
)) {
|
||||
const elementInSummary = summaryPage.elements.find(
|
||||
(e) => e.name === elementName
|
||||
);
|
||||
const summaryPage = summary.pages.find((p) => p.name === submissionEvent.data.pageName);
|
||||
if (summaryPage.type === "form" && submissionEvent.data.submission) {
|
||||
for (const [elementName, elementValue] of Object.entries(submissionEvent.data.submission)) {
|
||||
const elementInSummary = summaryPage.elements.find((e) => e.name === elementName);
|
||||
if (typeof elementInSummary !== "undefined") {
|
||||
if (
|
||||
elementInSummary.type === "text" ||
|
||||
elementInSummary.type === "textarea"
|
||||
["email", "number", "phone", "text", "textarea", "website"].includes(elementInSummary.type)
|
||||
) {
|
||||
if (!("summary" in elementInSummary)) {
|
||||
elementInSummary.summary = [];
|
||||
}
|
||||
elementInSummary.summary.push(elementValue);
|
||||
} else if (
|
||||
elementInSummary.type === "radio" ||
|
||||
elementInSummary.type === "checkbox"
|
||||
) {
|
||||
const optionInSummary = elementInSummary.options.find(
|
||||
(o) => o.value === elementValue
|
||||
);
|
||||
} else if (elementInSummary.type === "checkbox") {
|
||||
// checkbox values are a list of values
|
||||
for (const value of elementValue) {
|
||||
const optionInSummary = elementInSummary.options.find((o) => o.value === value);
|
||||
if (typeof optionInSummary !== "undefined") {
|
||||
if (!("summary" in optionInSummary)) {
|
||||
optionInSummary.summary = 0;
|
||||
}
|
||||
optionInSummary.summary += 1;
|
||||
}
|
||||
}
|
||||
} else if (elementInSummary.type === "radio") {
|
||||
const optionInSummary = elementInSummary.options.find((o) => o.value === elementValue);
|
||||
if (typeof optionInSummary !== "undefined") {
|
||||
if (!("summary" in optionInSummary)) {
|
||||
optionInSummary.summary = 0;
|
||||
31
apps/web/lib/telemetry.ts
Normal file
31
apps/web/lib/telemetry.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { hashString } from "./utils";
|
||||
|
||||
/* We use this telemetry service to better understand how snoopForms is being used
|
||||
and how we can improve it. All data including the IP address is collected anonymously
|
||||
and we cannot trace anything back to you or your customers. If you still want to
|
||||
disable telemetry, set the environment variable TELEMETRY_DISABLED=1 */
|
||||
|
||||
export const sendTelemetry = async (eventName: string) => {
|
||||
if (
|
||||
process.env.TELEMETRY_DISABLED !== "1" &&
|
||||
process.env.NODE_ENV === "production" &&
|
||||
process.env.NEXTAUTH_URL !== "http://localhost:3000"
|
||||
) {
|
||||
try {
|
||||
await fetch("https://posthog.snoopforms.com/capture/", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: "phc_BTq4eagaCzPyUSURXVYwlScTQRvcmBDXjYh7OG6kiqw",
|
||||
event: eventName,
|
||||
properties: {
|
||||
distinct_id: hashString(process.env.NEXTAUTH_URL),
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("error sending telemetry:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -40,9 +40,20 @@ export type SchemaPage = {
|
||||
elements: SchemaElement[];
|
||||
};
|
||||
|
||||
export type SnoopType =
|
||||
| "checkbox"
|
||||
| "email"
|
||||
| "number"
|
||||
| "phone"
|
||||
| "radio"
|
||||
| "submit"
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "website";
|
||||
|
||||
export type SchemaElement = {
|
||||
name: string;
|
||||
type: "checkbox" | "radio" | "text" | "textarea" | "submit";
|
||||
type: SnoopType;
|
||||
label?: string;
|
||||
options?: SchemaOption[];
|
||||
};
|
||||
@@ -64,7 +75,7 @@ export type SubmissionSummaryPage = {
|
||||
|
||||
export type SubmissionSummaryElement = {
|
||||
name: string;
|
||||
type: "checkbox" | "radio" | "text" | "textarea" | "submit";
|
||||
type: SnoopType;
|
||||
label?: string;
|
||||
summary?: string[];
|
||||
options?: SubmissionSummaryOption[];
|
||||
@@ -104,10 +115,7 @@ export type updateSchemaEvent = {
|
||||
data: Schema;
|
||||
};
|
||||
|
||||
export type ApiEvent =
|
||||
| pageSubmissionEvent
|
||||
| submissionCompletedEvent
|
||||
| updateSchemaEvent;
|
||||
export type ApiEvent = pageSubmissionEvent | submissionCompletedEvent | updateSchemaEvent;
|
||||
|
||||
export type WebhookEvent = Event & { formId: string; timestamp: string };
|
||||
|
||||
83
apps/web/lib/users.ts
Normal file
83
apps/web/lib/users.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { hashPassword } from "./auth";
|
||||
|
||||
export const createUser = async (firstname, lastname, email, password) => {
|
||||
const hashedPassword = await hashPassword(password);
|
||||
try {
|
||||
const res = await fetch(`/api/public/users`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
firstname,
|
||||
lastname,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
}),
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
const json = await res.json();
|
||||
throw Error(json.error);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
throw Error(`${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const resendVerificationEmail = async (email) => {
|
||||
try {
|
||||
const res = await fetch(`/api/public/users/verification-email`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
}),
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
const json = await res.json();
|
||||
throw Error(json.error);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
throw Error(`${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const forgotPassword = async (email: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/public/users/forgot-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
}),
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
const json = await res.json();
|
||||
throw Error(json.error);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
throw Error(`${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const resetPassword = async (token, password) => {
|
||||
const hashedPassword = await hashPassword(password);
|
||||
try {
|
||||
const res = await fetch(`/api/public/users/reset-password`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
hashedPassword,
|
||||
}),
|
||||
});
|
||||
if (res.status !== 200) {
|
||||
const json = await res.json();
|
||||
throw Error(json.error);
|
||||
}
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
throw Error(`${error.message}`);
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import intlFormat from "date-fns/intlFormat";
|
||||
import { formatDistance } from "date-fns";
|
||||
import crypto from "crypto";
|
||||
|
||||
export const fetcher = async (url) => {
|
||||
const res = await fetch(url);
|
||||
@@ -26,10 +27,7 @@ export const shuffle = (array) => {
|
||||
randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex--;
|
||||
|
||||
[array[currentIndex], array[randomIndex]] = [
|
||||
array[randomIndex],
|
||||
array[currentIndex],
|
||||
];
|
||||
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
|
||||
}
|
||||
|
||||
return array;
|
||||
@@ -116,3 +114,17 @@ export const timeSince = (dateString: string) => {
|
||||
addSuffix: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const generateId = (length) => {
|
||||
let result = "";
|
||||
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const charactersLength = characters.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const hashString = (string: string) => {
|
||||
return crypto.createHash("sha256").update(string).digest("hex");
|
||||
};
|
||||
0
next-env.d.ts → apps/web/next-env.d.ts
vendored
0
next-env.d.ts → apps/web/next-env.d.ts
vendored
@@ -3,6 +3,10 @@ var path = require("path");
|
||||
|
||||
const nextConfig = {
|
||||
reactStrictMode: false,
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
outputFileTracingRoot: path.join(__dirname, "../../"),
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
@@ -10,6 +14,11 @@ const nextConfig = {
|
||||
destination: "/forms/",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/forms/:id",
|
||||
destination: "/forms/:id/form",
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
|
||||
64
apps/web/package.json
Normal file
64
apps/web/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "^18.0.21"
|
||||
},
|
||||
"dependencies": {
|
||||
"@editorjs/editorjs": "^2.25.0",
|
||||
"@editorjs/header": "^2.6.2",
|
||||
"@editorjs/paragraph": "^2.8.0",
|
||||
"@headlessui/react": "^1.7.3",
|
||||
"@heroicons/react": "^2.0.12",
|
||||
"@snoopforms/react": "workspace:*",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"chart.js": "^3.9.1",
|
||||
"crypto": "^1.0.1",
|
||||
"date-fns": "^2.29.3",
|
||||
"editorjs-drag-drop": "^1.1.7",
|
||||
"editorjs-undo": "^2.0.9",
|
||||
"highlight.js": "^11.6.0",
|
||||
"json2csv": "^5.0.7",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"next": "12.3.1",
|
||||
"next-auth": "^4.14.0",
|
||||
"nextjs-cors": "^2.1.1",
|
||||
"nodemailer": "^6.8.0",
|
||||
"react": "18.2.0",
|
||||
"react-chartjs-2": "^4.3.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-icons": "^4.6.0",
|
||||
"react-loader-spinner": "^5.3.4",
|
||||
"react-toastify": "^9.0.8",
|
||||
"sanitize-html": "^2.7.2",
|
||||
"sharp": "^0.31.1",
|
||||
"swr": "^1.3.0",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@tailwindcss/typography": "^0.5.7",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/node": "18.11.0",
|
||||
"@types/react": "^18.0.21",
|
||||
"autoprefixer": "^10.4.12",
|
||||
"@formbricks/database": "workspace:*",
|
||||
"eslint": "8.25.0",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"postcss": "^8.4.18",
|
||||
"@formbricks/tailwind-config": "workspace:*",
|
||||
"tailwindcss": "^3.1.8",
|
||||
"ts-node": "^10.9.1",
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"typescript": "4.8.4"
|
||||
}
|
||||
}
|
||||
30
apps/web/pages/404.tsx
Normal file
30
apps/web/pages/404.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Image from "next/image";
|
||||
import BaseLayoutUnauthorized from "../components/layout/BaseLayoutUnauthorized";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Error404Page() {
|
||||
return (
|
||||
<BaseLayoutUnauthorized title="Page not found">
|
||||
<div className="bg-ui-gray-light flex min-h-screen">
|
||||
<div className="mx-auto flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
|
||||
<div className="mx-auto w-full max-w-sm p-8 lg:w-96">
|
||||
<div>
|
||||
<Image src="/img/snoopforms-logo.svg" alt="snoopForms logo" width={500} height={89} />
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<h1 className="leading-2 mb-4 text-center font-bold">This page does not exist!</h1>
|
||||
<p className="text-center">
|
||||
Sorry, the page you were looking for could not be found. Please make sure the URL is correct
|
||||
or{" "}
|
||||
<span className="underline">
|
||||
<Link href="/">go back to the homepage</Link>
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayoutUnauthorized>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import "../styles/globals.css";
|
||||
import "../styles/editorjs.css";
|
||||
import "../styles/toastify.css";
|
||||
import type { AppProps } from "next/app";
|
||||
import "highlight.js/styles/tokyo-night-dark.css";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
import "../styles/editorjs.css";
|
||||
// import "@formbricks/ui/styles.css";
|
||||
import "../styles/globals.css";
|
||||
import "../styles/toastify.css";
|
||||
|
||||
export default function App({
|
||||
Component,
|
||||
pageProps: { session, ...pageProps },
|
||||
}: AppProps) {
|
||||
function SnoopApp({ Component, pageProps: { session, ...pageProps } }) {
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<Component {...pageProps} />
|
||||
@@ -16,3 +14,5 @@ export default function App({
|
||||
</SessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default SnoopApp;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user