forms dashboard now entirely on react.

- setup js tooling (node modules and Makefile)
- setup prettier so js code can be standardized
- move js and scss source dirs around
- setup react and react-router, making them take control of
  /forms, /dashboard and /forms/<hashid> and so on
- remove all forms dashboard templates, moving their markup and logic to jsx
- remove unnecessary js code and rename submissions.scss to settings.scss
- create formspree/forms/api.py and move all actions created from the
  dashboard into there
- remove some bloat
This commit is contained in:
fiatjaf 2018-08-16 02:38:26 +00:00
parent 7ce164e945
commit 0eb778d803
58 changed files with 1691 additions and 10550 deletions

6
.babelrc Normal file
View File

@ -0,0 +1,6 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}

4
.gitignore vendored
View File

@ -5,6 +5,10 @@ npm-debug.log
ssl-keys
dart-sass
dump.rdb
formspree/static/bundle.js
formspree/static/bundle.min.js
formspree/static/main.css
*tmp-browserify*
# C extensions
*.so

19
Makefile Normal file
View File

@ -0,0 +1,19 @@
all: formspree/static/bundle.js formspree/static/main.css
watch:
find formspree/js/ formspree/scss/ -name '*.js' -o -name '*.scss' | entr make
$(shell find formspree/js/):
./node_modules/.bin/prettier "formspree/js/**/*.js"
formspree/static/bundle.js: $(shell find formspree/js)
./node_modules/.bin/browserify formspree/js/main.js -dv --outfile formspree/static/bundle.js
formspree/static/bundle.min.js: $(shell find formspree/js)
./node_modules/.bin/browserify formspree/js/main.js -g [ envify --NODE_ENV production ] -g uglifyify | ./node_modules/.bin/uglifyjs --compress --mangle > formspree/static/bundle.min.js
formspree/static/main.css: $(shell find formspree/scss) dart-sass/src/dart
cd dart-sass && ./sass ../formspree/scss/main.scss ../formspree/static/main.css
dart-sass/src/dart:
echo -e "\n\ninstall dart-sass from https://github.com/sass/dart-sass/releases\n\n"

203
formspree/forms/api.py Normal file
View File

@ -0,0 +1,203 @@
from flask import request, jsonify, g
from flask_login import current_user, login_required
from formspree import settings
from formspree.stuff import DB
from formspree.utils import jsonerror, IS_VALID_EMAIL
from .helpers import referrer_to_path, sitewide_file_check, remove_www, \
referrer_to_baseurl
from .models import Form, Submission
@login_required
def list():
# grab all the forms this user controls
if current_user.upgraded:
forms = current_user.forms.order_by(Form.id.desc()).all()
else:
forms = []
return jsonify({
'ok': True,
'user': {
'upgraded': current_user.upgraded,
'email': current_user.email
},
'forms': [f.serialize() for f in forms]
})
@login_required
def create():
# check that this request came from user dashboard to prevent XSS and CSRF
referrer = referrer_to_baseurl(request.referrer)
service = referrer_to_baseurl(settings.SERVICE_URL)
if referrer != service:
return jsonerror(400, {'error': 'Improper request.'})
if not current_user.upgraded:
g.log.info('Failed to create form from dashboard. User is not upgraded.')
return jsonerror(402, {'error': "Please upgrade your account."})
email = request.get_json().get('email')
url = request.get_json().get('url')
sitewide = request.get_json().get('sitewide')
g.log = g.log.bind(email=email, url=url, sitewide=sitewide)
if not IS_VALID_EMAIL(email):
g.log.info('Failed to create form from dashboard. Invalid address.')
return jsonerror(400, {'error': "The provided email address is not valid."})
g.log.info('Creating a new form from the dashboard.')
email = email.lower().strip() # case-insensitive
form = Form(email, owner=current_user)
if url:
url = 'http://' + url if not url.startswith('http') else url
form.host = referrer_to_path(url)
# sitewide forms, verified with a file at the root of the target domain
if sitewide:
if sitewide_file_check(url, email):
form.host = remove_www(referrer_to_path(urljoin(url, '/'))[:-1])
form.sitewide = True
else:
return jsonerror(403, {
'error': u"Couldn't verify the file at {}.".format(url)
})
DB.session.add(form)
DB.session.commit()
if form.host:
# when the email and url are provided, we can automatically confirm the form
# but only if the email is registered for this account
for email in current_user.emails:
if email.address == form.email:
g.log.info('No need for email confirmation.')
form.confirmed = True
DB.session.add(form)
DB.session.commit()
break
else:
# in case the email isn't registered for this user
# we automatically send the email confirmation
form.send_confirmation()
return jsonify({
'ok': True,
'hashid': form.hashid,
'submission_url': settings.API_ROOT + '/' + form.hashid,
'confirmed': form.confirmed
})
@login_required
def get(hashid):
if not current_user.upgraded:
return jsonerror(402, {'error': "Please upgrade your account."})
form = Form.get_with_hashid(hashid)
if not form:
return jsonerror(404, {'error': "Form not found."})
for cont in form.controllers:
if cont.id == current_user.id: break
else:
return jsonerror(401, {'error': "You do not control this form."})
submissions, fields = form.submissions_with_fields()
ret = form.serialize()
ret['submissions'] = submissions
ret['fields'] = fields
return jsonify(ret)
@login_required
def update(hashid):
# check that this request came from user dashboard to prevent XSS and CSRF
referrer = referrer_to_baseurl(request.referrer)
service = referrer_to_baseurl(settings.SERVICE_URL)
if referrer != service:
return jsonerror(400, {'error': 'Improper request.'})
form = Form.get_with_hashid(hashid)
if not form:
return jsonerror(400, {'error': 'Not a valid form.'})
if form.owner_id != current_user.id and form not in current_user.forms:
return jsonerror(401, {'error': 'Wrong user.'})
patch = request.get_json()
for attr in ['disable_storage', 'disabled', 'disable_email', 'captcha_disabled']:
if attr in patch:
setattr(form, attr, patch[attr])
DB.session.add(form)
DB.session.commit()
return jsonify({'ok': True})
@login_required
def delete(hashid):
# check that this request came from user dashboard to prevent XSS and CSRF
referrer = referrer_to_baseurl(request.referrer)
service = referrer_to_baseurl(settings.SERVICE_URL)
if referrer != service:
return jsonerror(400, {'error': 'Improper request.'})
form = Form.get_with_hashid(hashid)
if not form:
return jsonerror(400, {'error': 'Not a valid form.'})
if form.owner_id != current_user.id and form not in current_user.forms:
return jsonerror(401, {'error': 'Wrong user.'})
for submission in form.submissions:
DB.session.delete(submission)
DB.session.delete(form)
DB.session.commit()
return jsonify({'ok': True})
@login_required
def submission_delete(hashid, submissionid):
# check that this request came from user dashboard to prevent XSS and CSRF
referrer = referrer_to_baseurl(request.referrer)
service = referrer_to_baseurl(settings.SERVICE_URL)
if referrer != service:
return jsonerror(400, {'error': 'Improper request.'})
form = Form.get_with_hashid(hashid)
if not form:
return jsonerror(400, {'error': 'Not a valid form.'})
if form.owner_id != current_user.id and form not in current_user.forms:
return jsonerror(401, {'error': 'Wrong user.'})
submission = Submission.query.get(submissionid)
if not submission:
return jsonerror(401, 'Not a valid submission.')
DB.session.delete(submission)
form.counter -= 1
DB.session.add(form)
DB.session.commit()
return jsonify({'ok': True})
@login_required
def sitewide_check():
email = request.get_json().get('email')
url = request.get_json().get('url')
if sitewide_file_check(url, email):
return jsonify({'ok': True})
else:
return jsonify({'ok': False})

View File

@ -91,20 +91,22 @@ def sitewide_file_check(url, email):
g.log = g.log.bind(url=url, email=email)
res = requests.get(url, timeout=3, headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/55.0.2883.87 Chrome/55.0.2883.87 Safari/537.36'
})
if not res.ok:
g.log.debug('Sitewide file not found.', contents=res.text[:100])
return False
try:
res = requests.get(url, timeout=3, headers={
'User-Agent': 'Mozilla/5.0 (X11; Linux i686) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/55.0.2883.87 Chrome/55.0.2883.87 Safari/537.36'
})
if not res.ok:
g.log.debug('Sitewide file not found.', contents=res.text[:100])
return False
for line in res.text.splitlines():
line = line.strip(u'\xef\xbb\xbf ')
if line == email:
g.log.debug('Email found in sitewide file.')
return True
for line in res.text.splitlines():
line = line.strip(u'\xef\xbb\xbf ')
if line == email:
g.log.debug('Email found in sitewide file.')
return True
except requests.exceptions.ConnectionError:
pass
g.log.warn('Email not found in sitewide file.', contents=res.text[:100])
return False
@ -119,14 +121,6 @@ def verify_captcha(form_data, request):
return r.ok and r.json().get('success')
def valid_domain_request(request):
# check that this request came from user dashboard to prevent XSS and CSRF
referrer = referrer_to_baseurl(request.referrer)
service = referrer_to_baseurl(settings.SERVICE_URL)
return referrer == service
def assign_ajax(form, sent_using_ajax):
if form.uses_ajax is None:
form.uses_ajax = sent_using_ajax
@ -168,16 +162,3 @@ def fetch_first_submission(nonce):
return json.loads(jsondata.decode('utf-8'))
except:
return None
def check_valid_form_settings_request(form):
if not valid_domain_request(request):
return jsonify(error='The request you made is not valid.<br />Please visit your dashboard and try again.'), 400
if form.owner_id != current_user.id and form not in current_user.forms:
return jsonify(
error='You aren\'t the owner of that form.<br />Please log in as the form owner and try again.'), 400
if not form:
return jsonify(error='That form does not exist. Please check the link and try again.'), 400
return True

View File

@ -116,6 +116,47 @@ class Form(DB.Model):
except IndexError:
return None
def serialize(self):
return {
'sitewide': self.sitewide,
'hashid': self.hashid,
'hash': self.hash,
'counter': self.counter,
'email': self.email,
'host': self.host,
'confirm_sent': self.confirm_sent,
'confirmed': self.confirmed,
'disabled': self.disabled,
'captcha_disabled': self.captcha_disabled,
'disable_email': self.disable_email,
'disable_storage': self.disable_storage,
'is_public': bool(self.hash),
'url': '{S}/{E}'.format(
S=settings.SERVICE_URL,
E=self.hashid
)
}
def submissions_with_fields(self):
'''
Fetch all submissions, extract all fields names from every submission
into a single fields list, excluding the KEYS_NOT_STORED values, because
they are worthless.
Add the special 'date' field to every submission entry, based on
.submitted_at, and use this as the first field on the fields array.
'''
fields = set()
submissions = []
for s in self.submissions:
fields.update(s.data.keys())
s.data['date'] = s.submitted_at.isoformat()
s.data['id'] = s.id
submissions.append(s.data)
fields = ['date'] + sorted(fields - KEYS_NOT_STORED)
return submissions, fields
def send(self, data, keys, referrer):
'''
Sends form to user's email.

View File

@ -20,9 +20,7 @@ from formspree.utils import request_wants_json, jsonerror, IS_VALID_EMAIL, \
from .helpers import http_form_to_dict, ordered_storage, referrer_to_path, \
remove_www, referrer_to_baseurl, sitewide_file_check, \
verify_captcha, temp_store_hostname, get_temp_hostname, \
HASH, assign_ajax, valid_domain_request, \
KEYS_NOT_STORED, KEYS_EXCLUDED_FROM_EMAIL, \
check_valid_form_settings_request
HASH, assign_ajax, KEYS_EXCLUDED_FROM_EMAIL
from .models import Form, Submission
@ -486,349 +484,50 @@ def confirm_email(nonce):
@login_required
def forms():
'''
A reminder: this is the /forms endpoint, but for GET requests
it is also the /dashboard endpoint.
The /dashboard endpoint, the address gave by url_for('dashboard'),
is the target of a lot of redirects around the app, but it can
be changed later to point to somewhere else.
'''
# grab all the forms this user controls
if current_user.upgraded:
forms = current_user.forms.order_by(Form.id.desc()).all()
else:
forms = []
if request_wants_json():
return jsonify({
'ok': True,
'forms': [{
'email': f.email,
'host': f.host,
'confirm_sent': f.confirm_sent,
'confirmed': f.confirmed,
'is_public': bool(f.hash),
'url': '{S}/{E}'.format(
S=settings.SERVICE_URL,
E=f.hashid
)
} for f in forms]
})
else:
return render_template('forms/list.html',
enabled_forms=[form for form in forms if not form.disabled],
disabled_forms=[form for form in forms if form.disabled]
)
def serve_dashboard(hashid=None, s=None):
return render_template('forms/dashboard.html')
@login_required
def create_form():
# create a new form
if not current_user.upgraded:
g.log.info('Failed to create form from dashboard. User is not upgraded.')
return jsonerror(402, {'error': "Please upgrade your account."})
if request.get_json():
email = request.get_json().get('email')
url = request.get_json().get('url')
sitewide = request.get_json().get('sitewide')
else:
email = request.form.get('email')
url = request.form.get('url')
sitewide = request.form.get('sitewide')
g.log = g.log.bind(email=email, url=url, sitewide=sitewide)
if not IS_VALID_EMAIL(email):
g.log.info('Failed to create form from dashboard. Invalid address.')
if request_wants_json():
return jsonerror(400, {'error': "The provided email address is not valid."})
else:
flash(u'The provided email address is not valid.', 'error')
return redirect(url_for('dashboard'))
g.log.info('Creating a new form from the dashboard.')
email = email.lower() # case-insensitive
form = Form(email, owner=current_user)
if url:
url = 'http://' + url if not url.startswith('http') else url
form.host = referrer_to_path(url)
# sitewide forms, verified with a file at the root of the target domain
if sitewide:
if sitewide_file_check(url, email):
form.host = remove_www(referrer_to_path(urljoin(url, '/'))[:-1])
form.sitewide = True
else:
return jsonerror(403, {
'error': u"Couldn't verify the file at {}.".format(url)
})
DB.session.add(form)
DB.session.commit()
if form.host:
# when the email and url are provided, we can automatically confirm the form
# but only if the email is registered for this account
for email in current_user.emails:
if email.address == form.email:
g.log.info('No need for email confirmation.')
form.confirmed = True
DB.session.add(form)
DB.session.commit()
break
else:
# in case the email isn't registered for this user
# we automatically send the email confirmation
form.send_confirmation()
if request_wants_json():
return jsonify({
'ok': True,
'hashid': form.hashid,
'submission_url': settings.API_ROOT + '/' + form.hashid,
'confirmed': form.confirmed
})
else:
flash(u'Your new form endpoint was created!', 'success')
return redirect(url_for('dashboard', new=form.hashid) + '#form-' + form.hashid)
@login_required
def sitewide_check():
email = request.args.get('email')
url = request.args.get('url')
if sitewide_file_check(url, email):
return '', 200
else:
return '', 404
@login_required
def form_submissions(hashid, format=None):
def export_submissions(hashid, format=None):
if not current_user.upgraded:
return jsonerror(402, {'error': "Please upgrade your account."})
form = Form.get_with_hashid(hashid)
for cont in form.controllers:
if cont.id == current_user.id: break
else:
if request_wants_json():
return jsonerror(403, {'error': "You do not control this form."})
else:
return redirect(url_for('dashboard'))
return abort(401)
if not format:
# normal request.
if request_wants_json():
return jsonify({
submissions, fields = form.submissions_with_fields()
if format == 'json':
return Response(
json.dumps({
'host': form.host,
'email': form.email,
'submissions': [dict(s.data, date=s.submitted_at.isoformat()) for s in form.submissions]
})
else:
fields = set()
for s in form.submissions:
fields.update(s.data.keys())
fields -= KEYS_NOT_STORED
'fields': fields,
'submissions': submissions
}, sort_keys=True, indent=2),
mimetype='application/json',
headers={
'Content-Disposition': 'attachment; filename=form-%s-submissions-%s.json' \
% (hashid, datetime.datetime.now().isoformat().split('.')[0])
}
)
elif format == 'csv':
out = io.BytesIO()
w = csv.DictWriter(out, fieldnames=fields, encoding='utf-8')
w.writeheader()
for sub in submissions:
w.writerow(sub)
submissions = []
for sub in form.submissions:
for f in fields:
value = sub.data.get(f, '')
typ = type(value)
sub.data[f] = value if typ is str \
else pyaml.dump(value, safe=True)
submissions.append(sub)
return render_template('forms/submissions.html',
form=form,
fields=sorted(fields),
submissions=submissions
)
elif format:
# an export request, format can be json or csv
if format == 'json':
return Response(
json.dumps({
'host': form.host,
'email': form.email,
'submissions': [dict(s.data, date=s.submitted_at.isoformat()) for s in form.submissions]
}, sort_keys=True, indent=2),
mimetype='application/json',
headers={
'Content-Disposition': 'attachment; filename=form-%s-submissions-%s.json' \
% (hashid, datetime.datetime.now().isoformat().split('.')[0])
}
)
elif format == 'csv':
out = io.BytesIO()
fieldnames = set(field for sub in form.submissions for field in sub.data.keys())
fieldnames = ['date'] + sorted(fieldnames)
w = csv.DictWriter(out, fieldnames=fieldnames, encoding='utf-8')
w.writeheader()
for sub in form.submissions:
w.writerow(dict(sub.data, date=sub.submitted_at.isoformat()))
return Response(
out.getvalue(),
mimetype='text/csv',
headers={
'Content-Disposition': 'attachment; filename=form-%s-submissions-%s.csv' \
% (hashid, datetime.datetime.now().isoformat().split('.')[0])
}
)
@login_required
def form_recaptcha_toggle(hashid):
form = Form.get_with_hashid(hashid)
valid_check = check_valid_form_settings_request(form)
if valid_check != True:
return valid_check
checked_status = request.json['checked']
form.captcha_disabled = not checked_status
DB.session.add(form)
DB.session.commit()
if form.captcha_disabled:
return jsonify(disabled=True, message='CAPTCHA successfully disabled')
else:
return jsonify(disabled=False, message='CAPTCHA successfully enabled')
@login_required
def form_email_notification_toggle(hashid):
form = Form.get_with_hashid(hashid)
valid_check = check_valid_form_settings_request(form)
if valid_check != True:
return valid_check
checked_status = request.json['checked']
form.disable_email = not checked_status
DB.session.add(form)
DB.session.commit()
if form.disable_email:
return jsonify(disabled=True, message='Email notifications successfully disabled')
else:
return jsonify(disabled=False, message='Email notifications successfully enabled')
@login_required
def form_archive_toggle(hashid):
form = Form.get_with_hashid(hashid)
valid_check = check_valid_form_settings_request(form)
if valid_check != True:
return valid_check
checked_status = request.json['checked']
form.disable_storage = not checked_status
DB.session.add(form)
DB.session.commit()
if form.disable_storage:
return jsonify(disabled=True, message='Submission archive successfully disabled')
else:
return jsonify(disabled=False, message='Submission archive successfully enabled')
@login_required
def form_toggle(hashid):
form = Form.get_with_hashid(hashid)
# check that this request came from user dashboard to prevent XSS and CSRF
if not valid_domain_request(request):
return render_template('error.html',
title='Improper Request',
text='The request you made is not valid.<br />Please visit your dashboard and try again.'), 400
if form.owner_id != current_user.id:
if form not in current_user.forms: #accounts for bug when form isn't assigned owner_id bc it was not created from dashboard
return render_template('error.html',
title='Wrong user',
text='You aren\'t the owner of that form.<br />Please log in as the form owner and try again.'), 400
if not form:
return render_template('error.html',
title='Not a valid form',
text='That form does not exist.<br />Please check the link and try again.'), 400
else:
form.disabled = not form.disabled
DB.session.add(form)
DB.session.commit()
if form.disabled:
flash(u'Form successfully disabled', 'success')
else:
flash(u'Form successfully enabled', 'success')
return redirect(url_for('dashboard'))
@login_required
def form_deletion(hashid):
form = Form.get_with_hashid(hashid)
# check that this request came from user dashboard to prevent XSS and CSRF
referrer = referrer_to_baseurl(request.referrer)
service = referrer_to_baseurl(settings.SERVICE_URL)
if referrer != service:
return render_template('error.html',
title='Improper Request',
text='The request you made is not valid.<br />Please visit your dashboard and try again.'), 400
if form.owner_id != current_user.id:
if form not in current_user.forms: #accounts for bug when form isn't assigned owner_id bc it was not created from dashboard
return render_template('error.html',
title='Wrong user',
text='You aren\'t the owner of that form.<br />Please log in as the form owner and try again.'), 400
if not form:
return render_template('error.html',
title='Not a valid form',
text='That form does not exist.<br />Please check the link and try again.'), 400
else:
for submission in form.submissions:
DB.session.delete(submission)
DB.session.delete(form)
DB.session.commit()
flash(u'Form successfully deleted', 'success')
return redirect(url_for('dashboard'))
@login_required
def submission_deletion(hashid, submissionid):
submission = Submission.query.get(submissionid)
form = Form.get_with_hashid(hashid)
# check that this request came from user dashboard to prevent XSS and CSRF
referrer = referrer_to_baseurl(request.referrer)
service = referrer_to_baseurl(settings.SERVICE_URL)
if referrer != service:
return render_template('error.html',
title='Improper Request',
text='The request you made is not valid.<br />Please visit your dashboard and try again.'), 400
if form.owner_id != current_user.id:
if form not in current_user.forms: #accounts for bug when form isn't assigned owner_id bc it was not created from dashboard
return render_template('error.html',
title='Wrong user',
text='You aren\'t the owner of that form.<br />Please log in as the form owner and try again.' + str(form.id)), 400
if not submission:
return render_template('error.html',
title='Not a valid submission',
text='That submission does not exist.<br />Please check the link and try again.'), 400
elif submission.form_id != form.id:
return render_template('error.html',
title='Not a valid submissions',
text='That submission does not match the form provided.<br />Please check the link and try again.'), 400
else:
DB.session.delete(submission)
form.counter -= 1
DB.session.add(form)
DB.session.commit()
flash(u'Submission successfully deleted', 'success')
return redirect(url_for('form-submissions', hashid=hashid))
return Response(
out.getvalue(),
mimetype='text/csv',
headers={
'Content-Disposition': 'attachment; filename=form-%s-submissions-%s.csv' \
% (hashid, datetime.datetime.now().isoformat().split('.')[0])
}
)

View File

@ -0,0 +1,262 @@
/** @format */
const url = require('url')
const isValidUrl = require('valid-url').isWebUri
const isValidEmail = require('is-valid-email')
const React = require('react')
const toastr = window.toastr
const modals = require('../modals')
module.exports = class CreateForm extends React.Component {
constructor(props) {
super(props)
this.setEmail = this.setEmail.bind(this)
this.setURL = this.setURL.bind(this)
this.setSitewide = this.setSitewide.bind(this)
this.validate = this.validate.bind(this)
this.create = this.create.bind(this)
this.checkSitewide = this.checkSitewide.bind(this)
this.state = {
url: '',
email: '',
sitewide: false,
invalid: null,
verified: false,
disableVerification: false
}
}
componentDidMount() {
modals()
}
render() {
if (!this.props.user.upgraded) {
return (
<h6 className="light">
Please <a href="/account">upgrade your account</a> in order to create
forms from the dashboard and manage the forms currently associated
with your emails.
</h6>
)
}
let {
email,
url: urlv,
sitewide,
invalid,
verified,
disableVerification
} = this.state
return (
<div className="col-1-1">
<div className="create-form">
<a href="#create-form" className="button">
Create a form
</a>
<div className="modal" id="create-form" aria-hidden="true">
<div className="container">
<div className="x">
<h4>Create form</h4>
<a href="#">&times;</a>
</div>
<form onSubmit={this.create}>
<div className="col-1-1">
<h4>Send email to:</h4>
<input
type="email"
onChange={this.setEmail}
value={email}
placeholder="You can point this form to any email address"
/>
</div>
<div className="col-1-1">
<h4>From URL:</h4>
<input
type="url"
onChange={this.setURL}
value={urlv}
placeholder="Leave blank to send confirmation email when first submitted"
/>
</div>
<div className="container">
<div className="col-1-4">
<label
className="hint--bottom"
data-hint="A site-wide form is a form that you can place on all pages of your website -- and you just have to confirm once!"
>
<input
type="checkbox"
checked={sitewide}
onChange={this.setSitewide}
value="true"
/>
site-wide
</label>
</div>
<div className="col-3-4 info">
{invalid ? (
<div className="red">
{invalid === 'email' ? (
'Please input a valid email address.'
) : (
<>
Please input a valid URL, for example:
<span className="code">
{url.resolve(
'http://www.mywebsite.com',
sitewide ? '' : '/contact.html'
)}
</span>
</>
)}
</div>
) : !sitewide || (sitewide && verified) ? (
<div>&#8203;</div>
) : (
<span>
Please ensure
<span className="code">
{url.resolve(urlv, '/formspree-verify.txt')}
</span>
exists and contains a line with
<span className="code">{email}</span>
</span>
)}
</div>
</div>
<div className="col-1-3">
<div className="verify">
<button
style={sitewide ? {} : {visibility: 'hidden'}}
disabled={!sitewide && !invalid && !disableVerification}
onClick={this.checkSitewide}
>
Verify
</button>
</div>
</div>
<div className="col-1-3">&#8203;</div>
<div className="col-1-3">
<div className="create">
<button
type="submit"
disabled={
!((sitewide && verified) || (!sitewide && !invalid))
}
>
Create form
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
)
}
setEmail(e) {
this.setState({email: e.target.value}, this.validate)
}
setURL(e) {
this.setState({url: e.target.value}, this.validate)
}
setSitewide(e) {
this.setState({sitewide: e.target.checked}, this.validate)
}
validate() {
this.setState(st => {
st.invalid = null
let {email, url: urlv, sitewide} = st
urlv = /^https?:\/\//.test(urlv) ? urlv : 'http://' + urlv
if (!isValidEmail(email)) {
st.invalid = 'email'
return
}
if (sitewide) {
if (urlv && !isValidUrl(urlv)) {
st.invalid = 'urlv'
}
} else {
if (urlv && urlv !== 'http://' && !isValidUrl(urlv)) {
st.invalid = 'urlv'
}
}
return st
})
}
async checkSitewide(e) {
e.preventDefault()
try {
let r = await (await fetch(`/api/forms/sitewide-check`, {
method: 'POST',
body: JSON.stringify({email: this.state.email, url: this.state.url}),
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
})).json()
if (!r.ok) {
toastr.warning("The verification file wasn't found.")
this.setState({verified: false, disableVerification: true})
setTimeout(() => {
this.setState({disableVerification: false})
}, 5000)
return
}
toastr.success('The file exists! you can create your site-wide form now.')
this.setState({verified: true})
} catch (e) {
console.error(e)
toastr.error(e.message)
}
}
async create(e) {
e.preventDefault()
try {
let r = await (await fetch('/api/forms', {
method: 'POST',
body: JSON.stringify({
email: this.state.email,
url: this.state.url,
sitewide: this.state.sitewide
}),
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
})).json()
toastr.success('Form created!')
this.props.history.push(`/forms/${r.hashid}`)
} catch (e) {
console.error(e)
toastr.error(e.message)
}
}
}

View File

@ -0,0 +1,200 @@
/** @format */
const React = require('react')
const cs = require('class-set')
const {Link} = require('react-router-dom')
const createPortal = require('react-dom').createPortal
const CreateForm = require('./CreateForm')
const HeaderPortal = require('./HeaderPortal')
module.exports = class FormList extends React.Component {
constructor(props) {
super(props)
this.state = {
loading: true,
user: {},
enabled_forms: [],
disabled_forms: [],
error: null
}
}
async componentDidMount() {
try {
let r = await (await fetch('/api/forms', {
credentials: 'same-origin',
headers: {Accept: 'application/json'}
})).json()
this.setState({
user: r.user,
enabled_forms: r.forms.filter(f => !f.disabled),
disabled_forms: r.forms.filter(f => f.disabled),
loading: false
})
} catch (e) {
console.error(e)
this.setState({error: e.message})
}
}
render() {
if (this.state.loading) {
return (
<div className="col-1-1">
<p>loading...</p>
</div>
)
}
if (this.state.error) {
return (
<div className="col-1-1">
<p>
An error has ocurred while we were trying to fetch your forms,
please try again or contact us at support@formspree.io.
</p>
</div>
)
}
return (
<>
<HeaderPortal>
<h1>Your Forms</h1>
</HeaderPortal>
<div className="col-1-1">
<h4>Active Forms</h4>
{this.state.enabled_forms.length ? (
<table className="forms responsive">
<tbody>
{this.state.enabled_forms.map(form => (
<FormItem key={form.hashid} {...form} />
))}
</tbody>
</table>
) : (
<p>
No active forms found. Forms can be enabled by clicking the unlock
icon below.
</p>
)}
{this.state.disabled_forms.length ? (
<>
<h4>Disabled Forms</h4>
<table className="forms responsive">
<tbody>
{this.state.disabled_forms.map(form => (
<FormItem key={form.hashid} {...form} />
))}
</tbody>
</table>
</>
) : null}
{this.state.enabled_forms.length === 0 &&
this.state.disabled_forms.length === 0 &&
this.user.upgraded ? (
<h6 className="light">
You don't have any forms associated with this account, maybe you
should <a href="/account">verify your email</a>.
</h6>
) : null}
</div>
<CreateForm user={this.state.user} {...this.props} />
</>
)
}
}
class FormItem extends React.Component {
render() {
let form = this.props
return (
<tr
className={cs({
new: form.counter === 0,
verified: form.confirmed,
waiting_confirmation: form.confirm_sent
})}
>
<td data-label="Status">
<Link to={`/forms/${form.hashid}/settings`} className="no-underline">
{form.host ? (
form.confirmed ? (
<span
className="tooltip hint--right"
data-hint="Form confirmed"
>
<span className="ion-checkmark-round" />
</span>
) : form.confirm_sent ? (
<span
className="tooltip hint--right"
data-hint="Waiting confirmation"
>
<span className="ion-pause" />
</span>
) : null
) : (
<span
className="tooltip hint--right"
data-hint="New form. Place the code generated here in some page and submit it!"
>
<span className="ion-help" />
</span>
)}
</Link>
</td>
<td data-label="URL">
<Link to={`/forms/${form.hashid}/settings`} className="no-underline">
{form.host ? (
<div>
{form.host}
{form.sitewide ? (
<span
className="highlight tooltip hint--top"
data-hint="This form works for all paths under {{ form.host }}/"
>
/ *
</span>
) : null}
</div>
) : (
'Waiting for a submission'
)}
</Link>
</td>
<td className="target-email" data-label="Target email address">
<Link
to={`/forms/${form.hashid}/settings`}
className="no-underline"
>
<span
className="hint--top"
data-hint={`https://${location.host}/${
form.hash ? form.email : form.hashid
}`}
>
{form.email}
</span>
</Link>
</td>
<td className="n-submissions" data-label="Submissions counter">
<Link to={`/forms/${form.hashid}/submissions`} className="no-underline">
{form.counter == 0 ? (
<span className="never">never submitted</span>
) : (
`${form.counter} submissions`
)}
</Link>
</td>
</tr>
)
}
}

View File

@ -0,0 +1,476 @@
/** @format */
const toastr = window.toastr
const React = require('react')
const {Route, NavLink, Redirect} = require('react-router-dom')
const CodeMirror = require('react-codemirror2')
require('codemirror/mode/xml/xml')
const HeaderPortal = require('./HeaderPortal')
module.exports = class FormPage extends React.Component {
constructor(props) {
super(props)
this.state = {
form: null
}
this.fetchForm = this.fetchForm.bind(this)
}
async componentDidMount() {
this.fetchForm()
}
render() {
let hashid = this.props.match.params.hashid
return (
<>
<HeaderPortal>
<h1>{hashid}</h1>
<h3>
<NavLink
to={`/forms/${hashid}/submissions`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Submission History
</NavLink>
<NavLink
to={`/forms/${hashid}/settings`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Form Settings
</NavLink>
</h3>
</HeaderPortal>
<Route
exact
path={`/forms/${hashid}`}
render={() => <Redirect to={`/forms/${hashid}/submissions`} />}
/>
{this.state.form && (
<>
<Route
path="/forms/:hashid/submissions"
render={() => (
<FormSubmissions
form={this.state.form}
onUpdate={this.fetchForm}
/>
)}
/>
<Route
path="/forms/:hashid/settings"
render={() => (
<FormSettings
form={this.state.form}
history={this.props.history}
onUpdate={this.fetchForm}
/>
)}
/>
</>
)}
</>
)
}
async fetchForm() {
let hashid = this.props.match.params.hashid
try {
let r = await (await fetch(`/api/forms/${hashid}`, {
credentials: 'same-origin',
headers: {Accept: 'application/json'}
})).json()
this.setState({form: r})
} catch (e) {
console.error(e)
toastr.error(`Failed to fetch form, see the console for more details.`)
}
}
}
class FormSubmissions extends React.Component {
constructor(props) {
super(props)
this.deleteSubmission = this.deleteSubmission.bind(this)
}
render() {
let {form} = this.props
return (
<div className="col-1-1 submissions-col">
<h2>
Submissions for
{!form.hash ? (
<span className="code">/{form.hashid}</span>
) : (
<span className="code">/{form.email}</span>
)}
on <span className="code">{form.host}</span>
{form.sitewide ? 'and all its subpaths.' : null}
{form.hash ? (
<>
<br />
<small>
you can now replace the email in the URL with{' '}
<span className="code">{`/${form.hashid}`}</span>
</small>
</>
) : (
<>
<br />
<small>
targeting <span className="code">{form.email}</span>
</small>
</>
)}
</h2>
{form.submissions.length ? (
<table className="submissions responsive">
<thead>
<tr>
<th>Submitted at</th>
{form.fields.slice(1 /* the first field is 'date' */).map(f => (
<th key={f}>{f}</th>
))}
<th />
</tr>
</thead>
<tbody>
{form.submissions.map(s => (
<tr id={`submission-${s.id}`} key={s.id}>
<td id={`p-${s.id}`} data-label="Submitted at">
{new Date(Date.parse(s.date))
.toString()
.split(' ')
.slice(0, 5)
.join(' ')}
</td>
{form.fields
.slice(1 /* the first field is 'date' */)
.map(f => (
<td data-label={f} key={f}>
<pre>{s[f]}</pre>
</td>
))}
<td>
<button
className="no-border"
data-sub={s.id}
onClick={this.deleteSubmission}
>
<i className="fa fa-trash-o delete" />
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<h3>No submissions archived yet.</h3>
)}
</div>
)
}
async deleteSubmission(e) {
e.preventDefault()
let subid = e.currentTarget.dataset.sub
try {
let r = await (await fetch(
`/api/forms/${this.props.form.hashid}/submissions/${subid}`,
{
method: 'DELETE',
credentials: 'same-origin',
headers: {Accept: 'application/json'}
}
)).json()
if (r.error) {
toastr.warning(`Failed to delete submission: ${r.error}`)
return
}
toastr.success('Submission deleted.')
this.props.onUpdate()
} catch (e) {
console.error(e)
toastr.error(
'Failed to delete submission, see the console for more details.'
)
}
}
}
class FormSettings extends React.Component {
constructor(props) {
super(props)
this.update = this.update.bind(this)
this.deleteForm = this.deleteForm.bind(this)
this.cancelDelete = this.cancelDelete.bind(this)
this.state = {
deleting: false
}
}
render() {
let {form} = this.props
return (
<>
<div className="col-1-1" id="settings">
<h2>Sample HTML</h2>
<div className="container">
<div className="row">
<div className="col-1-1">
<p>
Use this code in your HTML, modifying it according to your
needs:
</p>
<CodeMirror.UnControlled
value={`<form
action="${form.url}"
method="POST"
>
<label>
Your email:
<input type="text" name="_replyto">
</label>
<label>
Your message:
<textarea name="message"></textarea>
</label>
<!-- your other form fields go here -->
<button type="submit">Send</button>
</form>`}
options={{
theme: 'oceanic-next',
mode: 'xml',
viewportMargin: Infinity
}}
/>
</div>
</div>
</div>
</div>
<div className="col-1-1" id="settings">
<h2>Form Settings</h2>
<div className="container">
<div className="row">
<div className="col-5-6">
<h4>Form Enabled</h4>
<p className="description">
You can disable this form to cause it to stop receiving new
submissions temporarily or permanently.
</p>
</div>
<div className="col-1-6 switch-row">
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={!form.disabled}
name="disabled"
/>
<span className="slider" />
</label>
</div>
</div>
<div className="row">
<div className="col-5-6">
<div>
<h4>reCAPTCHA</h4>
</div>
<p className="description">
reCAPTCHA provides vital spam protection, but you can turn it
off if you need.
</p>
</div>
<div className="col-1-6 switch-row">
<div>
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={!form.captcha_disabled}
name="captcha_disabled"
/>
<span className="slider" />
</label>
</div>
</div>
</div>
<div className="row">
<div className="col-5-6">
<h4>Email Notifications</h4>
<p className="description">
You can disable the emails Formspree sends if you just want to
download the submissions from the dashboard.
</p>
</div>
<div className="col-1-6 switch-row">
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={!form.disable_email}
name="disable_email"
/>
<span className="slider" />
</label>
</div>
</div>
<div className="row">
<div className="col-5-6">
<h4>Submission Archive</h4>
<p className="description">
You can disable the submission archive if you don't want
Formspree to store your submissions.
</p>
</div>
<div className="col-1-6 switch-row">
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={!form.disable_storage}
name="disable_storage"
/>
<span className="slider" />
</label>
</div>
</div>
<div className="row">
<div className={this.state.deleting ? 'col-1-2' : 'col-5-6'}>
<h4>
{this.state.deleting
? 'Are you sure you want to delete?'
: 'Delete Form'}
</h4>
<p className="description">
{this.state.deleting ? (
<span>
This will delete the form on <b>{form.host}</b> targeting{' '}
<b>{form.email}</b> and all its submissions? This action{' '}
<b>cannot</b> be undone.
</span>
) : (
<span>
Deleting the form will erase all traces of this form on
our databases, including all the submissions.
</span>
)}
</p>
</div>
<div
className={
(this.state.deleting ? 'col-1-2' : 'col-1-6') + ' switch-row'
}
>
{this.state.deleting ? (
<>
<button
onClick={this.deleteForm}
className="no-uppercase destructive"
>
Sure, erase everything
</button>
<button
onClick={this.cancelDelete}
className="no-uppercase"
style={{marginRight: '5px'}}
>
No, don't delete!
</button>
</>
) : (
<a onClick={this.deleteForm} href="#">
<i className="fa fa-trash-o delete" />
</a>
)}
</div>
</div>
</div>
</div>
</>
)
}
async update(e) {
try {
let res = await (await fetch(`/api/forms/${this.props.form.hashid}`, {
method: 'PATCH',
body: JSON.stringify({
[e.currentTarget.name]: !e.currentTarget.checked
}),
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
})).json()
if (res.error) {
toastr.warning(`Failed to save settings: ${res.error}`)
return
}
toastr.success('Settings saved.')
this.props.onUpdate()
} catch (e) {
console.error(e)
toastr.error('Failed to update form. See the console for more details.')
}
}
cancelDelete(e) {
e.preventDefault()
this.setState({deleting: false})
}
async deleteForm(e) {
e.preventDefault()
if (this.props.form.counter > 0 && !this.state.deleting) {
// double-check the user intentions to delete,
// but only if the form has been used already.
this.setState({deleting: true})
return
}
this.setState({deleting: false})
try {
let res = await (await fetch(`/api/forms/${this.props.form.hashid}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
Accept: 'application/json'
}
})).json()
if (res.error) {
toastr.warning(`Failed to delete form: ${res.error}`)
return
}
toastr.success('Form successfully deleted.')
this.props.history.push('/forms')
} catch (e) {
console.error(e)
toastr.error('Failed to delete form. See the console for more details.')
}
}
}

View File

@ -0,0 +1,7 @@
/** @format */
const createPortal = require('react-dom').createPortal
module.exports = function TitlePortal(props) {
return createPortal(props.children, document.querySelector('#header .center'))
}

View File

@ -0,0 +1,24 @@
/** @format */
const React = require('react')
const render = require('react-dom').render
const {BrowserRouter: Router, Route} = require('react-router-dom')
const FormList = require('./FormList')
const FormPage = require('./FormPage')
class Dashboard extends React.Component {
render() {
return (
<Router>
<>
<Route exact path="/forms" component={FormList} />
<Route exact path="/dashboard" component={FormList} />
<Route path="/forms/:hashid" component={FormPage} />
</>
</Router>
)
}
}
render(<Dashboard />, document.querySelector('.container.block'))

99
formspree/js/main.js Normal file
View File

@ -0,0 +1,99 @@
/**
* @format
*/
const $ = window.$
const StripeCheckout = window.StripeCheckout
const toastr = window.toastr
toastr.options = {positionClass: 'toast-top-center'}
/* top navbar */
var nav = $('body > nav')
nav.addClass('js')
nav.find('.menu').slicknav()
nav
.find('h4')
.clone()
.prependTo('.slicknav_menu')
/* adding a shadow at the bottom of the menu bar only when not at the top */
var w = $(window)
w.scroll(function() {
var scrollPos = w.scrollTop()
if (scrollPos && !nav.hasClass('scrolled')) {
nav.addClass('scrolled')
} else if (!scrollPos) {
nav.removeClass('scrolled')
}
})
/* background-color should inherit, but CSS's "inherit" breaks z-index */
var bgcolor = $(document.body).css('background-color')
if (bgcolor.split(',').length === 4 || bgcolor === 'transparent') {
bgcolor = 'white'
}
nav.css('background-color', bgcolor)
/* modals -- working with or without JS */
require('./modals')()
/* turning flask flash messages into js popup notifications */
window.popupMessages.forEach(function(m, i) {
var category = m[0] || 'info'
var text = m[1]
setTimeout(function() {
toastr[category](text)
}, (1 + i) * 1500)
})
/* stripe checkout */
var stripebutton = $('#stripe-upgrade')
if (stripebutton.length) {
var handler = StripeCheckout.configure(stripebutton.data())
stripebutton.on('click', function(e) {
handler.open({
token: function(token) {
stripebutton
.closest('form')
.append(
`<input type="hidden" name="stripeToken" value="${token.id}">`
)
.append(
`<input type="hidden" name="stripeEmail" value="${token.email}">`
)
.submit()
}
})
e.preventDefault()
})
}
/* quick script for showing the resend confirmation form */
$('a.resend').on('click', function() {
$(this).hide()
$('form.resend').show()
return false
})
/* scripts at other files */
require('./forms/dashboard.js')
/* toggle the card management menu */
$(function() {
$('#card-list tr:even').addClass('even')
$('#card-list tr:not(.even)').hide()
$('#card-list tr:first-child').show()
$('#card-list tr.even').click(function() {
$(this)
.next('tr')
.toggle()
$(this)
.find('.arrow')
.toggleClass('up')
$(this)
.find('.fa-chevron-right')
.toggleClass('fa-rotate-90')
})
})

53
formspree/js/modals.js Normal file
View File

@ -0,0 +1,53 @@
/** @format */
const $ = window.$
module.exports = function modals() {
$('.modal').each(function() {
let modal = $(this)
modal.addClass('js')
let id = modal.attr('id')
$(`[href="#${id}"]`).click(function(e) {
// open the modal
e.preventDefault()
modal.toggleClass('target')
})
modal.click(function(e) {
// close the modal
if (e.target === modal[0]) {
cleanHash()
modal.toggleClass('target')
e.preventDefault()
}
})
modal.find('.x a').click(function(e) {
// close the modal
cleanHash()
e.preventDefault()
modal.toggleClass('target')
})
})
function cleanHash() {
if (!window.location.hash) return
if (window.history && window.history.replaceState) {
window.history.replaceState('', document.title, window.location.pathname)
} else {
let pos = $(window).scrollTop()
window.location.hash = ''
$(window).scrollTop(pos)
}
}
// activate modals from url hash #
setTimeout(() => {
// setTimeout is needed because :target elements only appear after
// the page is loaded or something like that.
let activatedModal = $('*:target')
if (activatedModal.length && !activatedModal.is('.target')) {
activatedModal.toggleClass('target')
}
}, 0)
}

View File

@ -1,3 +1,5 @@
/** @format */
const url = require('url')
const isValidUrl = require('valid-url').isWebUri
const isValidEmail = require('is-valid-email')
@ -11,7 +13,7 @@ const $ = window.$
const toastr = window.toastr
/* create-form validation for site-wide forms */
module.exports = function sitewide () {
module.exports = function sitewide() {
var parentNode = $('#create-form .container')
if (!parentNode.length) return
@ -37,20 +39,29 @@ module.exports = function sitewide () {
parentNode.on('input', 'input[name="url"], input[name="email"]', run)
parentNode.on('click', '.verify button', check)
function run () {
function run() {
let checkbox = parentNode.find('input[name="sitewide"]')
let email = parentNode.find('input[name="email"]').val().trim()
let urlv = parentNode.find('input[name="url"]').val().trim()
let email = parentNode
.find('input[name="email"]')
.val()
.trim()
let urlv = parentNode
.find('input[name="url"]')
.val()
.trim()
urlv = /^https?:\/\//.test(urlv) ? urlv : 'http://' + urlv
let sitewide = checkbox.is(':checked')
// wrong input
if (!isValidEmail(email)) { // invalid email
if (!isValidEmail(email)) {
// invalid email
data.invalid = 'email'
} else if (sitewide && !isValidUrl(urlv)) { // invalid url with sitewide
} else if (sitewide && !isValidUrl(urlv)) {
// invalid url with sitewide
data.invalid = 'url'
} else if (!sitewide && urlv && urlv !== 'http://' && !isValidUrl(urlv)) { // invalid url without sitewide
} else if (!sitewide && urlv && urlv !== 'http://' && !isValidUrl(urlv)) {
// invalid url without sitewide
data.invalid = 'url'
} else {
data.invalid = null
@ -63,15 +74,17 @@ module.exports = function sitewide () {
apply(render(data))
}
function check () {
function check() {
$.ajax({
url: '/forms/sitewide-check?' + parentNode.find('form').serialize(),
success: function () {
toastr.success('The file exists! you can create your site-wide form now.')
success: function() {
toastr.success(
'The file exists! you can create your site-wide form now.'
)
data.verified = true
apply(render(data))
},
error: function () {
error: function() {
toastr.warning("The verification file wasn't found.")
data.verified = false
data.disableVerification = true
@ -87,17 +100,29 @@ module.exports = function sitewide () {
return false
}
function apply (vtree) {
function apply(vtree) {
let patches = diff(tree, vtree)
rootNode = patch(rootNode, patches)
tree = vtree
}
function render ({invalid, sitewide, verified, urlv, email, disableVerification}) {
function render({
invalid,
sitewide,
verified,
urlv,
email,
disableVerification
}) {
return h('form', {method: 'post', action: formActionURL}, [
h('.col-1-1', [
h('h4', 'Send email to:'),
h('input', {type: 'email', name: 'email', placeholder: emailPlaceholder, value: email})
h('input', {
type: 'email',
name: 'email',
placeholder: emailPlaceholder,
value: email
})
]),
h('.col-1-1', [
h('h4', 'From URL:'),
@ -112,35 +137,47 @@ module.exports = function sitewide () {
]),
h('.col-3-4.info', [
invalid
? h('div.red', invalid === 'email'
? 'Please input a valid email address.'
: [
'Please input a valid URL. For example: ',
h('span.code', url.resolve('http://www.mywebsite.com', sitewide ? '' : '/contact.html'))
])
: sitewide && verified || !sitewide
? h(
'div.red',
invalid === 'email'
? 'Please input a valid email address.'
: [
'Please input a valid URL. For example: ',
h(
'span.code',
url.resolve(
'http://www.mywebsite.com',
sitewide ? '' : '/contact.html'
)
)
]
)
: (sitewide && verified) || !sitewide
? h('div', {innerHTML: '&#8203;'})
: h('span', [
'Please ensure ',
h('span.code', url.resolve(urlv, '/formspree-verify.txt')),
' exists and contains a line with ',
h('span.code', email)
])
'Please ensure ',
h('span.code', url.resolve(urlv, '/formspree-verify.txt')),
' exists and contains a line with ',
h('span.code', email)
])
]),
h('.col-1-3', [
h('.verify', [
h('button', sitewide && !invalid && !disableVerification
? {}
: sitewide
? {disabled: true}
: {style: {visibility: 'hidden'}, disabled: true},
'Verify')
h(
'button',
sitewide && !invalid && !disableVerification
? {}
: sitewide
? {disabled: true}
: {style: {visibility: 'hidden'}, disabled: true},
'Verify'
)
])
]),
h('.col-1-3', {innerHTML: '&#8203;'}),
h('.col-1-3', [
h('.create', [
sitewide && verified || !sitewide && !invalid
(sitewide && verified) || (!sitewide && !invalid)
? h('button', {type: 'submit'}, 'Create form')
: h('button', {disabled: true}, 'Create form')
])

View File

@ -1,4 +1,5 @@
import formspree.forms.views as fv
import formspree.forms.api as fa
import formspree.users.views as uv
import formspree.static_pages.views as sv
@ -37,18 +38,18 @@ def configure_routes(app):
app.add_url_rule('/logout', 'logout', view_func=uv.logout, methods=['GET'])
# Users' forms
app.add_url_rule('/dashboard', 'dashboard', view_func=fv.forms, methods=['GET'])
app.add_url_rule('/forms', 'forms', view_func=fv.forms, methods=['GET'])
app.add_url_rule('/forms', 'create-form', view_func=fv.create_form, methods=['POST'])
app.add_url_rule('/forms/sitewide-check', view_func=fv.sitewide_check, methods=['GET'])
app.add_url_rule('/forms/<hashid>/', 'form-submissions', view_func=fv.form_submissions, methods=['GET'])
app.add_url_rule('/forms/<hashid>.<format>', 'form-submissions', view_func=fv.form_submissions, methods=['GET'])
app.add_url_rule('/forms/<hashid>/toggle-recaptcha', 'toggle-recaptcha', view_func=fv.form_recaptcha_toggle, methods=['POST'])
app.add_url_rule('/forms/<hashid>/toggle-emails', 'toggle-emails', view_func=fv.form_email_notification_toggle, methods=['POST'])
app.add_url_rule('/forms/<hashid>/toggle-storage', 'toggle-storage', view_func=fv.form_archive_toggle, methods=['POST'])
app.add_url_rule('/forms/<hashid>/toggle', 'form-toggle', view_func=fv.form_toggle, methods=['POST'])
app.add_url_rule('/forms/<hashid>/delete', 'form-deletion', view_func=fv.form_deletion, methods=['POST'])
app.add_url_rule('/forms/<hashid>/delete/<submissionid>', 'submission-deletion', view_func=fv.submission_deletion, methods=['POST'])
app.add_url_rule('/dashboard', 'dashboard', view_func=fv.serve_dashboard, methods=['GET'])
app.add_url_rule('/forms', 'dashboard', view_func=fv.serve_dashboard, methods=['GET'])
app.add_url_rule('/forms/<hashid>', view_func=fv.serve_dashboard, methods=['GET'])
app.add_url_rule('/forms/<hashid>/<path:s>', view_func=fv.serve_dashboard, methods=['GET'])
app.add_url_rule('/forms/<hashid>.<format>', view_func=fv.export_submissions, methods=['GET'])
app.add_url_rule('/api/forms', view_func=fa.list, methods=['GET'])
app.add_url_rule('/api/forms', view_func=fa.create, methods=['POST'])
app.add_url_rule('/api/forms/<hashid>', view_func=fa.get, methods=['GET'])
app.add_url_rule('/api/forms/<hashid>', view_func=fa.update, methods=['PATCH'])
app.add_url_rule('/api/forms/<hashid>', view_func=fa.delete, methods=['DELETE'])
app.add_url_rule('/api/forms/sitewide-check', view_func=fa.sitewide_check, methods=['POST'])
app.add_url_rule('/api/forms/<hashid>/submissions/<submissionid>', view_func=fa.submission_delete, methods=['DELETE'])
# Webhooks
app.add_url_rule('/webhooks/stripe', view_func=uv.stripe_webhook, methods=['POST'])

View File

@ -111,6 +111,14 @@
}
.dashboard {
#header {
h3 {
a {
margin: 0 10px;
}
}
}
.row:after {
content: "";
display: none;
@ -444,38 +452,16 @@
}
}
.html-highlight {
clear: both;
font-size: 14px;
font-family: monospace;
padding: 5px;
border: 2px dashed #696969;
border-radius: 5px;
.bracket {
color: #a65700
}
.tagname {
color: #800000;
font-weight: bold;
}
.attrkey {
color: #074726
}
.equal {
color: #808030
}
.attrvalue {
color: #0000e6
}
.comment {
color: #696969
}
}
a.button.export {
width: 11em;
text-align: center;
display: inline-block;
margin: 0 0 20px 20px;
}
.CodeMirror {
border: 1px solid #eee;
padding: 10px;
height: auto;
font-size: 90%;
}

View File

@ -1,7 +1,7 @@
// Ionicons Variables
// --------------------------
$ionicons-font-path: "../fonts" !default;
$ionicons-font-path: "fonts" !default;
$ionicons-font-family: "Ionicons" !default;
$ionicons-version: "2.0.1" !default;
$ionicons-prefix: ion- !default;
@ -738,4 +738,4 @@ $ionicon-var-wifi: "\f25c";
$ionicon-var-wineglass: "\f2b9";
$ionicon-var-woman: "\f25d";
$ionicon-var-wrench: "\f2ba";
$ionicon-var-xbox: "\f30c";
$ionicon-var-xbox: "\f30c";

View File

@ -22,7 +22,7 @@ $error-red: #CC3F36;
@import 'dashboard.scss';
@import 'nav.scss';
@import 'toastr.scss';
@import 'submissions.scss';
@import 'settings.scss';
@import 'ionicons/ionicons.scss';
html {

View File

@ -0,0 +1,89 @@
/** @format */
#settings {
p#status {
float: right;
color: $dark-blue;
font-size: 0.7em;
margin-right: 2%;
&.error {
color: $red;
}
}
h4 {
margin: 14px 0 4px 0 !important;
}
p.description {
line-height: 1em;
display: inline-block;
font-size: 0.8em;
margin: 0;
margin-top: 0.2em;
color: #999999;
}
.switch-row {
margin: auto;
position: relative;
text-align: right;
.switch {
position: absolute;
display: inline-block;
width: 60px;
height: 26px;
right: 5%;
input {
display: none;
}
}
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: 0.4s;
transition: 0.4s;
&:before {
position: absolute;
content: '';
height: 18px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: 0.4s;
transition: 0.4s;
}
}
input:checked + .slider {
background-color: $dark-green;
}
input:focus + .slider {
box-shadow: 0 0 1px $dark-green;
}
input:checked + .slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
.row {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,128 +0,0 @@
const $ = window.$
const StripeCheckout = window.StripeCheckout
const toastr = window.toastr
toastr.options = { positionClass: 'toast-top-center' }
/* top navbar */
var nav = $('body > nav')
nav.addClass('js')
nav.find('.menu').slicknav()
nav.find('h4').clone().prependTo('.slicknav_menu')
/* adding a shadow at the bottom of the menu bar only when not at the top */
var w = $(window)
w.scroll(function () {
var scrollPos = w.scrollTop()
if (scrollPos && !nav.hasClass('scrolled')) {
nav.addClass('scrolled')
} else if (!scrollPos) {
nav.removeClass('scrolled')
}
})
/* background-color should inherit, but CSS's "inherit" breaks z-index */
var bgcolor = $(document.body).css('background-color')
if (bgcolor.split(',').length === 4 || bgcolor === 'transparent') {
bgcolor = 'white'
}
nav.css('background-color', bgcolor)
/* modals -- working with or without JS */
function modals () {
$('.modal').each(function () {
let modal = $(this)
modal.addClass('js')
let id = modal.attr('id')
$(`[href="#${id}"]`).click(function (e) {
// open the modal
e.preventDefault()
modal.toggleClass('target')
})
modal.click(function (e) {
// close the modal
if (e.target === modal[0]) {
cleanHash()
modal.toggleClass('target')
e.preventDefault()
}
})
modal.find('.x a').click(function (e) {
// close the modal
cleanHash()
e.preventDefault()
modal.toggleClass('target')
})
})
function cleanHash () {
if (!window.location.hash) return
if (window.history && window.history.replaceState) {
window.history.replaceState('', document.title, window.location.pathname)
} else {
let pos = $(window).scrollTop()
window.location.hash = ''
$(window).scrollTop(pos)
}
}
// activate modals from url hash #
setTimeout(() => {
// setTimeout is needed because :target elements only appear after
// the page is loaded or something like that.
let activatedModal = $('*:target')
if (activatedModal.length && !activatedModal.is('.target')) {
activatedModal.toggleClass('target')
}
}, 0)
}
modals()
/* turning flask flash messages into js popup notifications */
window.popupMessages.forEach(function (m, i) {
var category = m[0] || 'info'
var text = m[1]
setTimeout(function () { toastr[category](text) }, (1 + i) * 1500)
})
/* stripe checkout */
var stripebutton = $('#stripe-upgrade')
if (stripebutton.length) {
var handler = StripeCheckout.configure(stripebutton.data())
stripebutton.on('click', function (e) {
handler.open({
token: function (token) {
stripebutton.closest('form')
.append(`<input type="hidden" name="stripeToken" value="${token.id}">`)
.append(`<input type="hidden" name="stripeEmail" value="${token.email}">`)
.submit()
}
})
e.preventDefault()
})
}
/* quick script for showing the resend confirmation form */
$('a.resend').on('click', function () {
$(this).hide()
$('form.resend').show()
return false
})
/* scripts at other files */
require('./sitewide')()
/* toggle the card management menu */
$(function () {
$("#card-list tr:even").addClass("even");
$("#card-list tr:not(.even)").hide();
$("#card-list tr:first-child").show();
$("#card-list tr.even").click(function () {
$(this).next("tr").toggle();
$(this).find(".arrow").toggleClass("up");
$(this).find(".fa-chevron-right").toggleClass("fa-rotate-90");
});
});

View File

@ -1,57 +0,0 @@
var settingsDropDownContent = $("#settings-dropdown-content")[0];
$(window).click(function(e) {
if(!e.target.matches('#settings-button') && e.target.nodeName != 'path' && e.target.nodeName != 'svg') {
if (settingsDropDownContent.classList.contains("show")) {
settingsDropDownContent.classList.remove("show");
}
}
});
$(document).ready(function() {
$("#settings-button").click(function() {
settingsDropDownContent.classList.toggle("show");
});
});
$(document).ready(function() {
$(':checkbox').change(function() {
targetAttributeBox = this;
var target;
var checkedStatus = this.checked;
var status = $("#status");
if (this.id == 'recaptcha') {
target = '/forms/' + hashid + '/toggle-recaptcha';
} else if (this.id == 'email-notifications') {
target = '/forms/' + hashid + '/toggle-emails';
} else if (this.id == 'submission-storage') {
target = '/forms/' + hashid + '/toggle-storage';
}
$.ajax({
url: target,
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({
checked: checkedStatus
}),
dataType: 'json',
beforeSend: function() {
status.removeClass("error");
status.html('Saving... <i class="fas fa-circle-notch fa-spin"></i>');
},
success: function (data) {
status.html('Saved <i class="fas fa-check-circle"></i>');
console.log(data);
},
error: function (data) {
status.html('Error saving <i class="fas fa-times-circle"></i>');
status.addClass("error");
targetAttributeBox.checked = !checkedStatus;
console.log(data);
}
});
})
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,128 +0,0 @@
.dropdown {
position: relative;
display: inline-block;
cursor: pointer;
}
.dropdown-content {
display: none;
position: absolute;
background-color: #f1f1f1;
min-width: 160px;
overflow: auto;
box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2);
z-index: 1;
}
.dropdown-content a {
color: black;
padding: 12px 16px;
text-decoration: none;
display: block;
}
.show {
display: block;
}
#settings-button {
display: block;
}
.modal {
.float-left {
float: left;
}
p#status {
float: right;
color: $dark-blue;
font-size: 0.7em;
margin-right: 2%;
&.error {
color: $red;
}
}
h4.large {
font-size: 1.2em;
}
p.description {
line-height: 1.0em;
display: inline-block;
font-size: 0.8em;
margin: 0;
margin-top: 0.2em;
color: #999999;
}
.col-1-6.switch-row {
margin: auto;
}
.switch {
position: absolute;
display: inline-block;
width: 60px;
height: 26px;
right: 5%;
//top: 25%;
}
.switch input {
display: none;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: .4s;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
}
input:checked + .slider {
background-color: $dark-green;
}
input:focus + .slider {
box-shadow: 0 0 1px $dark-green;
}
input:checked + .slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
.row {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-ms-flex-wrap: wrap;
flex-wrap: wrap;
margin-right: -15px;
margin-left: -15px;
}
}

View File

@ -1,51 +0,0 @@
<div class="col-1-1">
<div class="create-form">
{% if current_user.upgraded %}
<a href="#create-form" class="button">
Create a form
</a>
<div class="modal" id="create-form" aria-hidden="true">
<div class="container">
<div class="x"><h4>Create form</h4><a href="#">&times;</a></div>
<form method="POST" action="{{ url_for('create-form') }}">
<div class="col-1-1">
<h4>Send email to:</h4>
<input type="email" name="email" placeholder="You can point this form to any email address" value="{{ current_user.email }}">
</div>
<div class="col-1-1">
<h4>From URL:</h4>
<input type="url" name="url" placeholder="Leave blank to send confirmation email when first submitted">
</div>
<div class="container">
<div class="col-1-4">
<label class="hint--bottom" data-hint="A site-wide form is a form that you can place on all pages of your website -- and you just have to confirm once!">
<input type="checkbox" name="sitewide" value="true">
site-wide
</label>
</div>
<div class="col-3-4 info">
When creating a form that is valid site-wide, ensure you have a file named <span class="code">/formspree-verify.txt</span> at the root of your site and that it contains a line with the target email inside.
</div>
</div>
<div class="col-1-3">
<div class="verify">
<button>Verify</button>
</div>
</div>
<div class="col-1-3">
&#8203;
</div>
<div class="col-1-3">
<div class="create">
<button type="submit">Create form</button>
</div>
</div>
</form>
</div>
</div>
{% else %}
<h6 class="light">Please <a href="{{ url_for('account') }}">upgrade your account</a> in order to create forms from the dashboard and manage the forms currently associated with your emails.</h6>
{% endif %}
</div>
</div>

View File

@ -0,0 +1 @@
{% extends 'users/dashboard.html' %}

View File

@ -1,43 +0,0 @@
<tr class="{% if form.counter == 0 %}new{% endif %} {% if form.confirmed %}verified{% elif form.confirm_sent %}waiting_confirmation{% endif %}">
<td data-label="Status">
<a href="#form-{{ form.hashid }}" class="no-underline">
{% if not form.host %}
<span class="tooltip hint--right" data-hint="New form. Place the code generated here in some page and submit it!"><span class="ion-help"></span></span>
{% elif form.confirmed %}
<span class="tooltip hint--right" data-hint="Form confirmed"><span class="ion-checkmark-round"></span></span>
{% elif form.confirm_sent %}
<span class="tooltip hint--right" data-hint="Waiting confirmation"><span class="ion-pause"></span></span>
{% endif %}
</a>
</td>
<td data-label="URL">
<a href="#form-{{ form.hashid }}" class="no-underline">
{% if not form.host %}
Waiting for a submission
{% else %}
{{ form.host }}
{% if form.sitewide %}
<span class="highlight tooltip hint--top" data-hint="This form works for all paths under {{ form.host }}/">/ *</span>
{% endif %}
{% endif %}
</a>
</td>
<td class="target-email" data-label="Target email address">
<a href="#form-{{ form.hashid }}" class="no-underline">
<span class="hint--top" data-hint="{{ request.url_root.replace('http://', 'https://') }}{% if form.hash %}{{ form.email }}{% else %}{{ form.hashid }}{% endif %}">{{ form.email }}</span>
</a>
</td>
<td class="n-submissions" data-label="Submissions counter">
<a href="{{ url_for('form-submissions', hashid=form.hashid) }}" class="no-underline">
{% if form.counter == 0 %}
<span class="never">never submitted</span>
{% else %}
{{ form.counter }} submissions
{% endif %}
</a>
</td>
<td data-label="Disable"><form method="POST" action="{{ url_for('form-toggle', hashid=form.hashid) }}"><button class="no-border"><i class="fa fa-{% if form.disabled %}lock{% else %}unlock{% endif %} fa-fw"></i></button></form></td>
<td data-label="Delete">
<a href="#delete-{{form.hashid}}" class="no-underline"><i class="fa fa-trash-o delete"></i></a>
</td>
</tr>

View File

@ -1,87 +0,0 @@
{% extends 'users/dashboard.html' %}
{% block sectiontitle %}
<h1>Your forms</h1>
{% endblock %}
{% block section %}
<div class="col-1-1">
<h4>Active Forms</h4>
<table class="forms responsive">
{# status icon | host or form link | target email | number of submissions #}
{% for form in enabled_forms %}
{% include "forms/list-item.html" %}
{% else %}
<p>No active forms found. Forms can be enabled by clicking the unlock icon below.</p>
{% endfor %}
</table>
{% if disabled_forms %}
<h4>Disabled Forms</h4>
<table class="forms responsive">
{# status icon | host or form link | target email | number of submissions #}
{% for form in disabled_forms %}
{% include "forms/list-item.html" %}
{% endfor %}
</table>
{% endif %}
{% if not enabled_forms and not disabled_forms and current_user.upgraded %}
<h6 class="light">You don't have any forms associated with this account, maybe you should <a
href="{{ url_for('account') }}">verify your email</a>.</h6>
{% endif %}
</div>
{% include "forms/create.html" %}
{# modals for each form #}
{% for form in (enabled_forms + disabled_forms) %}
<div class="modal" id="form-{{ form.hashid }}" aria-hidden="true">
<div>
{% if request.args.get('new') == form.hashid %}
<div class="x"><h4>Congratulations!</h4><a href="#">&times;</a></div>
<p style="clear: both">Now that you have created your form, use the following HTML code in your website, filling it with the <span class="code">&lt;input&gt;</span> fields you wish your visitors to fill:</p>
{% else %}
<div class="x"><h4>Use this code in your HTML</h4><a href="#">&times;</a></div>
{% endif %}
<div class="html-highlight">
<span class="bracket">&lt;</span><span class="tagname">form</span>
<span class="attrkey">action</span><span class="equal">=</span><span
class="attrvalue">"{{ config.SERVICE_URL }}/{{ form.hashid }}"</span>
<span class="attrkey">method</span><span class="equal">=</span><span class="attrvalue">"POST"</span><span
class="bracket">></span>
<br>
&nbsp;&nbsp;<span class="bracket">&lt;</span><span class="tagname">input</span>
<span class="attrkey">type</span><span class="equal">=</span><span class="attrvalue">"text"</span>
<span class="attrkey">name</span><span class="equal">=</span><span class="attrvalue">"_gotcha"</span>
<span class="attrkey">style</span><span class="equal">=</span><span
class="attrvalue">"display: none"</span><span class="bracket">></span>
<br>
<br>
&nbsp;&nbsp;<span class="comment">&lt;!-- your other form fields go here --></span>
<br>
<br>
&nbsp;&nbsp;<span class="bracket">&lt;</span><span class="tagname">button</span>
<span class="attrkey">type</span><span class="equal">=</span><span class="attrvalue">"submit"</span><span class="bracket">></span>Send<span class="bracket">&lt;/</span><span class="tagname">button</span><span class="bracket">></span>
<br>
<span class="bracket">&lt;/</span><span class="tagname">form</span><span class="bracket">></span>
</div>
</div>
</div>
<div class="modal" id="delete-{{ form.hashid }}" aria-hidden="true">
<div>
<div class="x"><h4>Are you ABSOLUTELY sure?</h4><a href="#">&times;</a></div>
<br/>
<p>This action <strong>CANNOT</strong> be undone. This will permanently delete the form{% if form.host %} on
<strong>{{ form.host }}</strong>{% endif %} and all related submissions.</p>
<form method="POST" action="{{ url_for('form-deletion', hashid=form.hashid) }}">
<span class="cancel"><button type="button" onclick="return false;">Cancel</button></span>
<button class="delete">Confirm deletion</button>
</form>
</div>
</div>
{% endfor %}
{% endblock %}

View File

@ -1,131 +0,0 @@
{% extends 'users/dashboard.html' %}
{% block sectiontitle %}
<h1>Submission History</h1>
{% endblock %}
{% block section %}
<div class="col-1-1 submissions-col">
<h2>
Submissions for
{% if not form.hash %}
<span class="code">/{{ form.hashid }}</span>
{% else %}
<span class="code">/{{ form.email }}</span>
{% endif %}
at <span class="code">{{ form.host }}</span>{% if form.sitewide %} and all its subpaths.{% endif %}
{% if not form.hash %}
<br><small>targeting <span class="code">{{ form.email }}</span></small>
{% else %}
<br><small>you can now replace the email in the URL with <span class="code">/{{ form.hashid }}</span></small>
{% endif %}
</h2>
{% if submissions %}
<table class="submissions responsive">
<thead>
<tr>
<th>Submitted at</th>
{% for f in fields %}
<th>{{ f }}</th>
{% endfor %}
<th></th>
</tr>
</thead>
<tbody>
{% for s in submissions %}
<tr id="submission-{{ s.id }}">
<td id="p-{{ s.id }}" data-label="Submitted at">{{ s.submitted_at.strftime('%A, %B %d, %Y at %X') }} UTC</td>
{% for f in fields %}
{% set value = s.data[f] or '' %}
<td data-label="{{ f }}">
{% if 320 < (value | length) %}
<pre>{{ value[:270] }}&hellip; <a href=#submission-{{ s.id }}>(see more)</a></pre>
<pre class="full">{{ value }} <a href=#p-{{ s.id }}>(see less)</a></pre>
{% else %}
<pre>{{ value }}</pre>
{% endif %}
</td>
{% endfor %}
<td><form method="POST" action="{{ url_for('submission-deletion', hashid=form.hashid, submissionid=s.id) }}"><button class="no-border"><i class="fa fa-trash-o delete"></i></button></form></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<h3>No submissions archived yet.</h3>
{% endif %}
</div>
</div>
<div class="container block">
<div class="col-1-2 left">
<a href="#create-form" class="button">
<i class="fas fa-cogs"></i> Settings
</a>
</div>
<div class="col-1-2 right">
<div class="dropdown">
<a id="settings-button" class="button">Export <i class="fas fa-arrow-circle-down" id="settings-gears"></i></a>
<div id="settings-dropdown-content" class="dropdown-content">
<a href="{{ url_for('form-submissions', hashid=form.hashid, format='csv') }}" target="_blank">Export&nbsp;as&nbsp;CSV</a>
<a href="{{ url_for('form-submissions', hashid=form.hashid, format='json') }}" target="_blank">Export&nbsp;as&nbsp;JSON</a>
</div>
</div>
</div>
<div class="modal" id="create-form" aria-hidden="true">
<div class="container">
<div class="x"><h4 class="large">Form Settings</h4><a href="#">&times;</a></div>
<div class="row">
<div class="col-5-6">
<div><h4>reCAPTCHA</h4></div>
<p class="description">reCAPTCHA provides vital spam protection. You can turn off reCAPTCHA to remove the intermediate screen.</p>
</div>
<div class="col-1-6 switch-row">
<div><label class="switch">
<input type="checkbox"{% if not form.captcha_disabled %} checked{% endif %} id="recaptcha">
<span class="slider"></span>
</label>
</div>
</div>
</div>
<div class="row">
<div class="col-5-6">
<h4>Email Notifications</h4>
<p class="description">You can disable email notifications if you want to download all submissions from the Formspree dashboard.</p>
</div>
<div class="col-1-6 switch-row">
<label class="switch">
<input type="checkbox"{% if not form.disable_email %} checked{% endif %} id="email-notifications">
<span class="slider"></span>
</label>
</div>
</div>
<div class="row">
<div class="col-5-6">
<h4>Submission Archive</h4>
<p class="description">You can disable the submission archive if you don't want Formspree to store your submissions.</p>
</div>
<div class="col-1-6 switch-row">
<label class="switch">
<input type="checkbox"{% if not form.disable_storage %} checked{% endif %} id="submission-storage">
<span class="slider"></span>
</label>
</div>
</div>
<p id="status">Saved <i class="fas fa-check-circle"></i></p>
</div>
</div>
</div>
<script>
const hashid = '{{ form.hashid }}';
</script>
<script src="{{ url_for('static', filename='js/submissions.js') }}"></script>
{% endblock %}

View File

@ -22,18 +22,15 @@
<!-- end google optimize and analytics -->
<script src="//use.typekit.net/{{config.TYPEKIT_KEY}}.js"></script>
<script>try{Typekit.load();}catch(e){}</script>
<link rel="stylesheet" href="{{url_for('static', filename='css/main.css')}}">
<link rel="icon" type="image/png" href="{{config.SERVICE_URL}}/static/img/favicon.ico">
<meta name="viewport" content="initial-scale=1">
<script defer src="https://use.fontawesome.com/releases/v5.0.9/js/all.js"></script>
<script defer src="https://use.fontawesome.com/releases/v5.0.9/js/v4-shims.js"></script>
{# <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css">#}
{#<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.0.9/css/all.css" integrity="sha384-5SOiIsAziJl6AWe0HWRKTXlfcSHKmYV4RBF18PPJ173Kzn7jzMyFuTtk8JA7QQG1" crossorigin="anonymous">#}
<script src="{{ url_for('static', filename='js/vendor/jquery.min.js') }}"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<script type="text/javascript" src="https://js.stripe.com/v3/"></script>
{% block head_scripts %}{% endblock %}
<link rel="stylesheet" href="{{url_for('static', filename='main.css')}}">
</head>
<body class="{% block bodyclass %}{% endblock %}" id="{% block bodyid %}card{% endblock %}">
@ -65,7 +62,7 @@
</div>
<div class="menu">
{% if request.path != '/' %}<span class="item"><a href="/">Home</a></span>{% endif %}
<span class="item"><a href="{{ url_for('forms') }}">Your forms</a></span>
<span class="item"><a href="{{ url_for('dashboard') }}">Your forms</a></span>
<span class="item"><a href="{{ url_for('account') }}">{% if current_user.upgraded %}Your{% else %}Upgrade{% endif %} account</a></span>
{% else %}
<div class="greetings">
@ -104,6 +101,6 @@
<script type="text/javascript">
Stripe.setPublishableKey('{{config.STRIPE_PUBLISHABLE_KEY}}');
</script>
<script src="{{ url_for('static', filename='js/bundle.js') }}"></script>
<script src="{{ url_for('static', filename='bundle.js' if config.DEBUG else 'bundle.prod.js') }}"></script>
</body>
</html>

View File

@ -1,5 +1,11 @@
{% extends 'static_pages/index.html' %}
{% block head_scripts %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/6.26.0/polyfill.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.39.2/codemirror.min.css" rel="stylesheet"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.39.2/theme/oceanic-next.css" rel="stylesheet"></script>
{% endblock %}
{% block bodyclass %}dashboard{% endblock%}
{% block base %}

View File

@ -1,24 +1,55 @@
{
"babel": {
"presets": [
"es2015"
"dependencies": {
"class-set": "^0.0.4",
"codemirror": "^5.39.2",
"is-valid-email": "0.0.2",
"react": "^16.4.2",
"react-codemirror2": "^5.1.0",
"react-dom": "^16.4.2",
"react-router-dom": "^4.3.1",
"url": "^0.11.0",
"valid-url": "^1.0.9"
},
"browserify": {
"transform": [
[
"babelify",
{
"sourceMaps": true,
"sourceMapsAbsolute": true
}
]
]
},
"dependencies": {
"is-valid-email": "0.0.2",
"url": "^0.11.0",
"valid-url": "^1.0.9",
"virtual-dom": "^2.1.1"
},
"devDependencies": {
"babel-core": "^6.6.5",
"babel-preset-es2015": "^6.6.0",
"babelify": "^7.2.0",
"browserify": "^13.0.0"
"@babel/core": "^7.0.0-rc.1",
"@babel/preset-env": "^7.0.0-rc.1",
"@babel/preset-react": "^7.0.0-rc.1",
"babelify": "^9.0.0",
"browserify": "^16.2.2",
"envify": "^4.1.0",
"prettier": "1.14.2",
"uglify-js": "^3.4.7",
"uglifyify": "^5.0.1"
},
"scripts": {
"build-js": "browserify formspree/static/js/main.js -t babelify --outfile formspree/static/js/bundle.js",
"build-css": "scss --sourcemap=none formspree/static/scss/main.scss formspree/static/css/main.css",
"build": "npm run build-js && npm run build-css"
"prettier": {
"semi": false,
"arrowParens": "avoid",
"insertPragma": true,
"printWidth": 80,
"proseWrap": "preserve",
"singleQuote": true,
"trailingComma": "none",
"useTabs": false,
"jsxBracketSameLine": false,
"bracketSpacing": false
},
"eslintConfig": {
"overrides": {
"files": [
"*"
],
"rules": []
}
}
}