mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 06:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13a1835c00 | ||
|
|
c35407d7a9 | ||
|
|
e55ccc0b76 | ||
|
|
b8d00870b4 | ||
|
|
5413fbc248 | ||
|
|
2d2eb7ce4d | ||
|
|
ed2ddbf74e | ||
|
|
b99e5d1262 | ||
|
|
87f6878eda | ||
|
|
99b815eb4b | ||
|
|
e40a29b9ab | ||
|
|
151890bb72 | ||
|
|
78edd87cc1 | ||
|
|
88c99a2b35 | ||
|
|
282b8cfa73 | ||
|
|
e9888c7466 | ||
|
|
0647befb3a | ||
|
|
85aa3d0451 | ||
|
|
6d2b3bb654 | ||
|
|
b394854e9d | ||
|
|
2e586e930e | ||
|
|
8f8f69f0bc | ||
|
|
04663d55eb | ||
|
|
93d0b935e2 | ||
|
|
829b2b2855 | ||
|
|
cf6de444f4 | ||
|
|
126f5d58a2 | ||
|
|
09b93067f4 | ||
|
|
f14458f238 | ||
|
|
445139f05c | ||
|
|
e5e4965f05 | ||
|
|
1c8f7b0316 | ||
|
|
9fe3656652 | ||
|
|
17ae46bd61 | ||
|
|
a036483938 | ||
|
|
3c65b0e4ba | ||
|
|
ced5ab67ab | ||
|
|
28c4177128 | ||
|
|
5d86a08b5f | ||
|
|
a69fb81429 | ||
|
|
e6ac3380d4 | ||
|
|
3a535e7112 | ||
|
|
7464f9cfad | ||
|
|
58e24ef758 | ||
|
|
c1d809f989 | ||
|
|
80b24f0e2a | ||
|
|
2e253904a3 | ||
|
|
a7f381caf0 | ||
|
|
c6a7555f54 | ||
|
|
0772880bbc | ||
|
|
73339d8cd9 | ||
|
|
25504e8cb1 | ||
|
|
63a874528a | ||
|
|
c1c99621ff | ||
|
|
5c168f4a29 | ||
|
|
bef825c4e0 | ||
|
|
a990d22831 | ||
|
|
1687a372ae | ||
|
|
9b267d4a07 | ||
|
|
2189c2e9fe | ||
|
|
1e43fe8969 | ||
|
|
f77a716658 | ||
|
|
a70ac571e3 | ||
|
|
4f5e72f983 | ||
|
|
8f47ec6d4e | ||
|
|
9e1bbf2944 | ||
|
|
aa7d6e9aaa | ||
|
|
57c8be33ce | ||
|
|
ed7109bfc6 | ||
|
|
9e2590489b | ||
|
|
d793f00b37 | ||
|
|
5896c9a2aa | ||
|
|
78b0ce9828 | ||
|
|
e187644716 | ||
|
|
a847e7aa9c | ||
|
|
0905e7d215 | ||
|
|
91147d2f48 | ||
|
|
acce6195b0 | ||
|
|
ce9ab691ec | ||
|
|
4190544edc | ||
|
|
db8e5efbd5 | ||
|
|
a5c6f4e40c | ||
|
|
7537d05d1f | ||
|
|
44dcc7f312 | ||
|
|
218c22da21 | ||
|
|
a8433e9bd3 | ||
|
|
800101f747 | ||
|
|
f637ee907c | ||
|
|
6a9b123cdc | ||
|
|
0a2f8dfab3 | ||
|
|
3dc00ed7fc | ||
|
|
af160b3f3f | ||
|
|
1a9e4026d9 | ||
|
|
5ef32bcf9e | ||
|
|
2d6f4c521e | ||
|
|
439f95d87e | ||
|
|
ca7b9671f2 | ||
|
|
20ec614d3a | ||
|
|
887ebbbcf6 | ||
|
|
4225295295 | ||
|
|
89367bc905 | ||
|
|
4bacaa26e1 | ||
|
|
0f9fcf3fdc | ||
|
|
437f61dc26 | ||
|
|
e55542a000 | ||
|
|
e004ac2d40 | ||
|
|
92bfedd01d | ||
|
|
e7fd1e4006 | ||
|
|
56bba60d3f | ||
|
|
6b6a917f60 | ||
|
|
df7b3f9dfe | ||
|
|
1b21b0c65b | ||
|
|
258133d65c | ||
|
|
8f33c8d8b6 | ||
|
|
e11f4bddbe | ||
|
|
d071e8c429 | ||
|
|
79aa034183 | ||
|
|
f5dad673de | ||
|
|
7e8375aee8 | ||
|
|
1999611b26 | ||
|
|
188c3a8c84 | ||
|
|
d35538e2ba | ||
|
|
a86f896694 | ||
|
|
35eeeba0c3 | ||
|
|
e3e5c237d4 | ||
|
|
9e89cb92a9 | ||
|
|
827850646d | ||
|
|
eafae4cda2 | ||
|
|
e012b7f5ac | ||
|
|
da9e7c6366 | ||
|
|
20cc5d1e2e | ||
|
|
ae2cedbd70 | ||
|
|
1f46f74400 | ||
|
|
930445258d | ||
|
|
be533e492e | ||
|
|
66758d45bc | ||
|
|
6f31bd7af8 | ||
|
|
f4d2fa4c28 | ||
|
|
5d3369d556 | ||
|
|
5ea55c733f | ||
|
|
547bc9aa94 | ||
|
|
53ca45a67a | ||
|
|
ef28b80589 | ||
|
|
05fa75f9a2 | ||
|
|
87b57279a4 | ||
|
|
f09732df90 | ||
|
|
a588c2f4a4 | ||
|
|
960d36f628 | ||
|
|
456dbae1cd | ||
|
|
0fc1c0d1ed | ||
|
|
714324ea99 | ||
|
|
5a29ed22db | ||
|
|
061580594c | ||
|
|
8723ce7f7a | ||
|
|
522df7f476 | ||
|
|
026637c5ab | ||
|
|
fde993a579 | ||
|
|
a960ea268a | ||
|
|
d59776e8f5 | ||
|
|
27e5c2c81e | ||
|
|
0609a70544 | ||
|
|
3ed15e383b | ||
|
|
d29be7c2cf | ||
|
|
79f72c92b3 | ||
|
|
7e6331d1ad | ||
|
|
0986dfba73 | ||
|
|
7cf0b5aaf7 | ||
|
|
ae5f87f8fb | ||
|
|
8259ebdb6c | ||
|
|
a4ca0abba1 | ||
|
|
ca009252ac | ||
|
|
9678cd1286 | ||
|
|
8cb0a7a020 | ||
|
|
d3cc2e1ca8 | ||
|
|
08ac60c7e6 | ||
|
|
49afb2e87c | ||
|
|
be621339c3 | ||
|
|
5f5b49a1ba | ||
|
|
c0dd4b85ed | ||
|
|
95f7cd5220 | ||
|
|
f0753de99e | ||
|
|
38ecda82fa | ||
|
|
073859a4b7 | ||
|
|
44972fcc39 | ||
|
|
1232dfb248 | ||
|
|
5de334522c | ||
|
|
7a6b4acea6 | ||
|
|
198a599a2b | ||
|
|
d36f40cafd | ||
|
|
9abdb68bc5 | ||
|
|
359f695eb5 | ||
|
|
2b9e5a86b6 | ||
|
|
c07d76e2a4 | ||
|
|
f5b29642ee | ||
|
|
3f6cdcd15b | ||
|
|
bde8097288 | ||
|
|
617d75a4ed | ||
|
|
aa1874cdb4 | ||
|
|
22b2e7475a | ||
|
|
ad3367dae0 | ||
|
|
47c090e1f1 | ||
|
|
678997fb36 | ||
|
|
24f195c7e5 | ||
|
|
5741bb4db2 | ||
|
|
1d169668f0 | ||
|
|
ef3d5bec7e | ||
|
|
d578ca3cc9 | ||
|
|
5432978c0d | ||
|
|
de1af49348 | ||
|
|
2f0bcb4a80 | ||
|
|
11f27c8cf0 |
No files matched your search
@@ -1,6 +1,11 @@
|
||||
---
|
||||
EnableDefaultLinters: true
|
||||
linters:
|
||||
AllowedScriptType:
|
||||
enabled: true
|
||||
allowed_types:
|
||||
- text/javascript
|
||||
- application/json
|
||||
ErbSafety:
|
||||
enabled: false
|
||||
HardCodedString:
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 4.0.5
|
||||
ruby-version: 4.0.9
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 4.0.5
|
||||
ruby-version: 4.0.9
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 4.0.5
|
||||
ruby-version: 4.0.9
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
container:
|
||||
image: ruby:4.0.5-alpine
|
||||
image: ruby:4.0.9-alpine
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -152,11 +152,7 @@ jobs:
|
||||
bundle config path vendor/bundle
|
||||
bundle install --jobs 4 --retry 4
|
||||
yarn install
|
||||
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip"
|
||||
case "$(uname -m)" in
|
||||
x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;;
|
||||
aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;;
|
||||
esac | sha256sum -c -
|
||||
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/latest/download/pdfium-musl-$(uname -m).zip"
|
||||
unzip -q pdfium-linux.zip -d /tmp/pdfium-linux
|
||||
cp /tmp/pdfium-linux/lib/libpdfium.so /usr/lib/libpdfium.so
|
||||
rm -rf pdfium-linux.zip /tmp/pdfium-linux
|
||||
|
||||
+11
-8
@@ -1,4 +1,4 @@
|
||||
FROM ruby:4.0.5-alpine AS download
|
||||
FROM ruby:4.0.9-alpine AS download
|
||||
|
||||
WORKDIR /fonts
|
||||
|
||||
@@ -9,15 +9,15 @@ RUN apk --no-cache add wget unzip && \
|
||||
wget https://raw.githubusercontent.com/impallari/DancingScript/master/OFL.txt && \
|
||||
wget https://raw.githubusercontent.com/notofonts/noto-fonts/refs/heads/main/LICENSE && \
|
||||
wget -O /model.onnx "https://github.com/docusealco/fields-detection/releases/download/2.0.0/model_704_int8.onnx" && \
|
||||
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260813/pdfium-musl-$(uname -m).zip" && \
|
||||
wget -O pdfium-linux.zip "https://github.com/docusealco/pdfium-binaries/releases/download/20260920/pdfium-musl-$(uname -m).zip" && \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) echo "c5c7dde243ecb66ab0819c8193515ef38ad53549fe260f3c2dfd93ea56eda2e7 pdfium-linux.zip" ;; \
|
||||
aarch64) echo "64c4483449b1b4dccc696ad0c5c96e0b7f74dcc57b4f23c676b7a70671b0bbb5 pdfium-linux.zip" ;; \
|
||||
x86_64) echo "bbca8a648dbd1ba81f9c6c223f21dc394cf1ad381c6c17d60c6dcc4a7d3a2ae0 pdfium-linux.zip" ;; \
|
||||
aarch64) echo "73b72ed79b9bfdd494dee7e863482c24971a8d4b11f6cd1289aa170d31304a1f pdfium-linux.zip" ;; \
|
||||
esac | sha256sum -c - && \
|
||||
mkdir -p /pdfium-linux && \
|
||||
unzip -q pdfium-linux.zip -d /pdfium-linux
|
||||
|
||||
FROM ruby:4.0.5-alpine AS webpack
|
||||
FROM ruby:4.0.9-alpine AS webpack
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV NODE_ENV=production
|
||||
@@ -44,7 +44,7 @@ COPY ./app/views ./app/views
|
||||
|
||||
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
|
||||
|
||||
FROM ruby:4.0.5-alpine AS app
|
||||
FROM ruby:4.0.9-alpine AS app
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV BUNDLE_WITHOUT="development:test"
|
||||
@@ -52,7 +52,8 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \
|
||||
RUN apk upgrade --no-cache ca-certificates-bundle && \
|
||||
apk add --no-cache libpq vips redis onnxruntime leptonica jemalloc font-liberation font-carlito && \
|
||||
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
|
||||
|
||||
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
|
||||
@@ -91,7 +92,7 @@ COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
|
||||
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN mkdir -p /app/public/fonts && ln -s /fonts/DancingScript-Regular.otf /app/public/fonts/ && \
|
||||
mkdir -p /usr/share/fonts/noto && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/ && fc-cache -f && \
|
||||
mkdir -p /usr/share/fonts/noto/bold && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/bold/ && fc-cache -f && \
|
||||
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
|
||||
chown -R docuseal:docuseal /app/tmp/cache
|
||||
|
||||
@@ -100,6 +101,8 @@ ENV HOME=/home/docuseal
|
||||
ENV WORKDIR=/data/docuseal
|
||||
ENV VIPS_MAX_COORD=17000
|
||||
ENV VIPS_BLOCK_UNTRUSTED=1
|
||||
# ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
|
||||
ENV MALLOC_CONF=dirty_decay_ms:1000,narenas:2,background_thread:true
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '4.0.5'
|
||||
ruby '4.0.9'
|
||||
|
||||
gem 'addressable'
|
||||
gem 'arabic-letter-connector', require: false
|
||||
@@ -45,6 +45,7 @@ gem 'trilogy', require: false
|
||||
gem 'turbo-rails'
|
||||
gem 'twitter_cldr', require: false
|
||||
gem 'tzinfo-data'
|
||||
gem 'wordcraft', require: false, group: :ce
|
||||
gem 'xlsxtream', require: false
|
||||
|
||||
group :development, :test do
|
||||
|
||||
+18
-7
@@ -497,7 +497,7 @@ GEM
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.2.2)
|
||||
rubyzip (3.7.0)
|
||||
securerandom (0.4.1)
|
||||
semantic_range (3.1.1)
|
||||
shakapacker (9.7.0)
|
||||
@@ -524,11 +524,11 @@ GEM
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sqlite3 (2.9.5-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.5-aarch64-linux-musl)
|
||||
sqlite3 (2.9.5-arm64-darwin)
|
||||
sqlite3 (2.9.5-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.5-x86_64-linux-musl)
|
||||
sqlite3 (2.9.6-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.6-aarch64-linux-musl)
|
||||
sqlite3 (2.9.6-arm64-darwin)
|
||||
sqlite3 (2.9.6-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.6-x86_64-linux-musl)
|
||||
strip_attributes (2.0.1)
|
||||
activemodel (>= 3.0, < 9.0)
|
||||
strscan (3.1.8)
|
||||
@@ -572,6 +572,16 @@ GEM
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
wordcraft (0.11.0-aarch64-linux-gnu)
|
||||
ffi (>= 1.15)
|
||||
wordcraft (0.11.0-aarch64-linux-musl)
|
||||
ffi (>= 1.15)
|
||||
wordcraft (0.11.0-arm64-darwin)
|
||||
ffi (>= 1.15)
|
||||
wordcraft (0.11.0-x86_64-linux-gnu)
|
||||
ffi (>= 1.15)
|
||||
wordcraft (0.11.0-x86_64-linux-musl)
|
||||
ffi (>= 1.15)
|
||||
xlsxtream (3.1.0)
|
||||
zip_kit (>= 6.2, < 7)
|
||||
xpath (3.2.0)
|
||||
@@ -650,10 +660,11 @@ DEPENDENCIES
|
||||
tzinfo-data
|
||||
web-console
|
||||
webmock
|
||||
wordcraft
|
||||
xlsxtream
|
||||
|
||||
RUBY VERSION
|
||||
ruby 4.0.5
|
||||
ruby 4.0.9
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.3
|
||||
@@ -40,7 +40,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
- Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
||||
- Automatic PDF eSignature
|
||||
- PDF signature verification
|
||||
- Users management
|
||||
- User management
|
||||
- Mobile-optimized
|
||||
- 7 UI languages with signing available in 14 languages
|
||||
- API and Webhooks for integrations
|
||||
|
||||
@@ -23,7 +23,7 @@ module Api
|
||||
|
||||
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
|
||||
|
||||
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
|
||||
if FilenameUtils.dangerous_extension(blob.filename)
|
||||
Rollbar.error('Dangerous extension') if defined?(Rollbar)
|
||||
|
||||
return head :unprocessable_content
|
||||
|
||||
@@ -19,7 +19,7 @@ module Api
|
||||
|
||||
return head :not_found unless blob
|
||||
|
||||
if Submitters::DANGEROUS_EXTENSIONS.include?(blob.filename.extension.to_s.downcase)
|
||||
if FilenameUtils.dangerous_extension(blob.filename)
|
||||
Rollbar.error('Dangerous extension') if defined?(Rollbar)
|
||||
|
||||
return head :unprocessable_content
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class AttachmentsController < ActionController::API
|
||||
include ActionController::Cookies
|
||||
include ActiveStorage::SetCurrent
|
||||
|
||||
COOKIE_STORE_LIMIT = 10
|
||||
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
unless can_upload?(@submitter)
|
||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
file = params[:file]
|
||||
|
||||
if params[:type].in?(%w[initials signature])
|
||||
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
|
||||
|
||||
if ImageUtils.blank?(image)
|
||||
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
if ImageUtils.error?(image)
|
||||
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} error, try to sign on another device" },
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
|
||||
end
|
||||
|
||||
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
|
||||
|
||||
if params[:remember_signature] == 'true' && @submitter.email.present?
|
||||
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
|
||||
end
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
rescue Submitters::MaliciousFileExtension => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def can_upload?(submitter)
|
||||
!submitter.declined_at? &&
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at?
|
||||
end
|
||||
|
||||
def build_new_cookie_signatures_json(submitter, attachment)
|
||||
values =
|
||||
begin
|
||||
JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}')
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
values[submitter.email] = attachment.uuid
|
||||
|
||||
values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT
|
||||
|
||||
values.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -98,7 +98,7 @@ module Api
|
||||
|
||||
render json: build_create_json(submissions)
|
||||
rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
|
||||
DownloadUtils::UnableToDownload => e
|
||||
Submitters::SubmitValues::ValidationError, DownloadUtils::UnableToDownload => e
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
@@ -173,7 +173,6 @@ module Api
|
||||
end
|
||||
|
||||
def filter_submissions(submissions, params)
|
||||
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
|
||||
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
|
||||
|
||||
if params[:template_folder].present?
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class SubmitterEmailClicksController < ApiBaseController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
|
||||
end
|
||||
|
||||
render json: {}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class SubmitterFormViewsController < ApiBaseController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
class SubmitterFormViewsController < ActionController::API
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
|
||||
@@ -78,7 +78,8 @@ module Api
|
||||
|
||||
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
|
||||
with_events: false, params:)
|
||||
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e
|
||||
rescue Submitters::NormalizeValues::BaseError, Submitters::SubmitValues::ValidationError,
|
||||
DownloadUtils::UnableToDownload => e
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
@@ -178,10 +179,7 @@ module Api
|
||||
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||
submitters = submitters.where(slug: params[:slug]) if params[:slug].present?
|
||||
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
|
||||
|
||||
if params[:template_id].present?
|
||||
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
|
||||
end
|
||||
submitters = submitters.where(template_id: params[:template_id]) if params[:template_id].present?
|
||||
|
||||
maybe_filter_by_completed_at(submitters, params)
|
||||
end
|
||||
|
||||
@@ -13,13 +13,17 @@ class ApplicationController < ActionController::Base
|
||||
before_action :maybe_redirect_to_setup, unless: :signed_in?
|
||||
before_action :authenticate_user!, unless: :devise_controller?
|
||||
|
||||
before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO'] }
|
||||
before_action :set_csp, if: lambda {
|
||||
request.get? && !request.headers['HTTP_X_TURBO_REQUEST_ID'] && !turbo_infinite_scroll?
|
||||
}
|
||||
|
||||
helper_method :button_title,
|
||||
:current_account,
|
||||
:true_ability,
|
||||
:form_link_host,
|
||||
:svg_icon
|
||||
:svg_icon,
|
||||
:turbo_native_ios?,
|
||||
:turbo_native_android?
|
||||
|
||||
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
|
||||
|
||||
@@ -64,6 +68,28 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
private
|
||||
|
||||
def turbo_infinite_scroll?
|
||||
request.headers['HTTP_X_TURBO_INFINITE_SCROLL'].present?
|
||||
end
|
||||
|
||||
def turbo_native_app?
|
||||
return @turbo_native_app if defined?(@turbo_native_app)
|
||||
|
||||
@turbo_native_app = request.user_agent.to_s.include?('Hotwire Native')
|
||||
end
|
||||
|
||||
def turbo_native_ios?
|
||||
return @turbo_native_ios if defined?(@turbo_native_ios)
|
||||
|
||||
@turbo_native_ios = request.user_agent.to_s.include?('Hotwire Native iOS')
|
||||
end
|
||||
|
||||
def turbo_native_android?
|
||||
return @turbo_native_android if defined?(@turbo_native_android)
|
||||
|
||||
@turbo_native_android = request.user_agent.to_s.include?('Hotwire Native Android')
|
||||
end
|
||||
|
||||
def with_locale(&)
|
||||
return yield unless current_account
|
||||
|
||||
@@ -92,7 +118,12 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def sign_in_for_demo
|
||||
sign_in(User.active.order('random()').take) unless signed_in?
|
||||
return if signed_in?
|
||||
|
||||
account = Account.create!(name: "Demo #{SecureRandom.hex(3)}")
|
||||
|
||||
sign_in(account.users.create!(first_name: 'Demo', last_name: 'User',
|
||||
email: "#{SecureRandom.hex}@docuseal.com", password: SecureRandom.hex))
|
||||
end
|
||||
|
||||
def current_account
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EnquiriesController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
if params[:talk_to_sales] == 'on'
|
||||
Faraday.post(Docuseal::ENQUIRIES_URL,
|
||||
enquiry_params.merge(type: :talk_to_sales).to_json,
|
||||
'Content-Type' => 'application/json')
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enquiry_params
|
||||
params.require(:user).permit(:email)
|
||||
end
|
||||
end
|
||||
@@ -46,6 +46,8 @@ module Mcp
|
||||
|
||||
authorize!(:create, @template)
|
||||
|
||||
Templates.maybe_assign_access(@template)
|
||||
|
||||
if mcp_params['url'].present?
|
||||
tempfile = Tempfile.new
|
||||
tempfile.binmode
|
||||
|
||||
@@ -33,7 +33,7 @@ class MfaSetupController < ApplicationController
|
||||
def destroy
|
||||
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
|
||||
|
||||
if current_user.validate_and_consume_otp!(params[:otp_attempt])
|
||||
if current_user.valid_for_authentication? { current_user.validate_and_consume_otp!(params[:otp_attempt]) }
|
||||
current_user.update!(otp_required_for_login: false, otp_secret: nil)
|
||||
|
||||
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
|
||||
|
||||
@@ -26,8 +26,9 @@ class PreviewDocumentPageController < ActionController::API
|
||||
|
||||
@template = attachment.record
|
||||
|
||||
preview_image = attachment.preview_images.joins(:blob)
|
||||
.find_by(blob: { filename: ["#{params[:id]}.png", "#{params[:id]}.jpg"] })
|
||||
preview_image =
|
||||
attachment.preview_images.joins(:blob)
|
||||
.find_by(blob: { filename: ["#{params[:id].to_i}.png", "#{params[:id].to_i}.jpg"] })
|
||||
|
||||
if preview_image
|
||||
return redirect_to preview_image.url(time: ActiveStorage::Attachment.service_url_time),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SearchController < ApplicationController
|
||||
load_and_authorize_resource :template_folder, parent: false
|
||||
load_and_authorize_resource :template, parent: false
|
||||
load_and_authorize_resource :submission, parent: false
|
||||
load_and_authorize_resource :template, instance_name: :filter_template, id_param: :template_id, parent: true
|
||||
|
||||
FOLDERS_LIMIT = 4
|
||||
LIMIT = 12
|
||||
|
||||
def index
|
||||
@query = params[:q].to_s.squish
|
||||
|
||||
return render_infinite_scroll(@template_folders, @templates, @submissions, @query) if turbo_infinite_scroll?
|
||||
|
||||
if @query.blank?
|
||||
@template_folders = @templates = @submissions = []
|
||||
elsif params[:scope] == 'folders'
|
||||
@folders_pagy, @template_folders =
|
||||
pagy(:countless, filter_folders(@template_folders, @templates, @query), limit: LIMIT)
|
||||
|
||||
@templates = @submissions = []
|
||||
else
|
||||
folders = filter_folders(@template_folders, @templates, @query)
|
||||
|
||||
@pagy, @template_folders, @templates, @submissions =
|
||||
if params[:scope] == 'submissions'
|
||||
load_submissions_page(folders, @templates, @submissions, @query)
|
||||
else
|
||||
load_templates_page(folders, @templates, @submissions, @query)
|
||||
end
|
||||
|
||||
if @templates.blank? && @submissions.blank? && params[:template_id].blank?
|
||||
@folders_pagy, @template_folders = pagy(:countless, folders, limit: LIMIT)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_submissions_page(folders, templates, submissions, query)
|
||||
pagy, submissions = pagy(:countless, filter_submissions(submissions, query), limit: LIMIT)
|
||||
|
||||
return [pagy, [], [], submissions] if pagy.next || params[:template_id].present?
|
||||
|
||||
[pagy, folders.limit(FOLDERS_LIMIT).to_a, filter_templates(templates, query).limit(LIMIT).to_a, submissions]
|
||||
end
|
||||
|
||||
def load_templates_page(folders, templates, submissions, query)
|
||||
pagy, templates = pagy(:countless, filter_templates(templates, query), limit: LIMIT)
|
||||
|
||||
submissions = pagy.next ? [] : filter_submissions(submissions, query).limit(LIMIT).to_a
|
||||
|
||||
[pagy, folders.limit(FOLDERS_LIMIT).to_a, templates, submissions]
|
||||
end
|
||||
|
||||
def render_infinite_scroll(folders, templates, submissions, query)
|
||||
target, partial, collection =
|
||||
case params[:scope]
|
||||
when 'folders'
|
||||
['search_folders', 'search/folder', filter_folders(folders, templates, query)]
|
||||
when 'submissions'
|
||||
['search_submissions', 'search/submission', filter_submissions(submissions, query)]
|
||||
else
|
||||
['search_templates', 'search/template', filter_templates(templates, query)]
|
||||
end
|
||||
|
||||
pagy, records = pagy(:countless, collection, limit: LIMIT)
|
||||
|
||||
render turbo_stream: [
|
||||
turbo_stream.append(target, partial:, collection: records),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def filter_folders(folders, templates, query)
|
||||
return folders.none if params[:archived] == 'true'
|
||||
|
||||
folders = folders.where.not(id: current_account.default_template_folder.id)
|
||||
|
||||
folders = folders.where(parent_folder_id: filter_folder_ids(params[:folder])) if params[:folder].present?
|
||||
folders = folders.where(author_id: filter_author_id(params[:author])) if params[:author].present?
|
||||
folders = TemplateFolders.filter_active_folders(folders, templates)
|
||||
folders = TemplateFolders.search(folders, query)
|
||||
|
||||
folders.preload(:parent_folder).order(:name)
|
||||
end
|
||||
|
||||
def filter_templates(templates, query)
|
||||
templates = params[:archived] == 'true' ? templates.where.not(archived_at: nil) : templates.active
|
||||
templates = templates.select_for_list.preload(:author, :template_accesses, folder: :parent_folder)
|
||||
|
||||
templates = templates.where(folder_id: filter_folder_ids(params[:folder])) if params[:folder].present?
|
||||
templates = templates.where(author_id: filter_author_id(params[:author])) if params[:author].present?
|
||||
|
||||
Templates.search(current_user, templates, query).order(id: :desc)
|
||||
end
|
||||
|
||||
def filter_folder_ids(folder_name)
|
||||
TemplateFolders.filter_by_full_name(current_account.template_folders, folder_name)
|
||||
.preload(:subfolders)
|
||||
.flat_map { |folder| [folder.id, *folder.subfolders.map(&:id)] }
|
||||
end
|
||||
|
||||
def filter_author_id(email)
|
||||
current_account.users.find_by(email:)&.id || -1
|
||||
end
|
||||
|
||||
def filter_submissions(submissions, query)
|
||||
submissions = submissions.left_joins(:template)
|
||||
|
||||
submissions =
|
||||
if params[:archived] == 'true'
|
||||
submissions.where.not(archived_at: nil).or(submissions.where.not(templates: { archived_at: nil }))
|
||||
else
|
||||
submissions.where(archived_at: nil).where(templates: { archived_at: nil })
|
||||
end
|
||||
|
||||
submissions = Submissions.search(current_user, submissions, query, search_template: true)
|
||||
submissions = Submissions::Filter.call(submissions, current_user, params)
|
||||
|
||||
submissions =
|
||||
if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
|
||||
submissions.order(completed_at: :desc)
|
||||
else
|
||||
submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
submissions.select_for_list.preload(:submitters, :template)
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,10 @@
|
||||
class SessionsController < Devise::SessionsController
|
||||
before_action :configure_permitted_parameters
|
||||
|
||||
skip_before_action :verify_authenticity_token, if: -> { request.xhr? && request.format.json? }
|
||||
|
||||
respond_to :json, only: :create
|
||||
|
||||
around_action :with_browser_locale
|
||||
|
||||
def create
|
||||
@@ -11,12 +15,26 @@ class SessionsController < Devise::SessionsController
|
||||
if Docuseal.multitenant? && !User.exists?(email:)
|
||||
Rollbar.warning('Sign in new user') if defined?(Rollbar)
|
||||
|
||||
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
|
||||
notice: I18n.t('create_a_new_account')
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
|
||||
notice: t('create_a_new_account')
|
||||
end
|
||||
format.json do
|
||||
render json: { sign_up: true, notice: t('create_a_new_account') }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
|
||||
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content
|
||||
respond_to do |format|
|
||||
format.html { render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content }
|
||||
format.json { render json: { otp_required: true, email_otp: false }, status: :unprocessable_content }
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
super
|
||||
|
||||
@@ -29,5 +29,17 @@ class SubmissionsArchivedController < ApplicationController
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
|
||||
associations: :author).call
|
||||
|
||||
render_infinite_scroll(@pagy, @submissions) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, submissions)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
|
||||
locals: { with_template: true, archived: true }),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,16 @@ class SubmissionsController < ApplicationController
|
||||
'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan'
|
||||
}.freeze
|
||||
|
||||
STATUS_BADGES = {
|
||||
'awaiting' => 'badge-info', 'sent' => 'badge-info', 'completed' => 'badge-success',
|
||||
'opened' => 'badge-warning', 'declined' => 'badge-error'
|
||||
}.freeze
|
||||
|
||||
STATUS_ICONS = {
|
||||
'awaiting' => 'clock', 'sent' => 'send', 'completed' => 'circle_check',
|
||||
'opened' => 'mail_opened', 'declined' => 'x_circle'
|
||||
}.freeze
|
||||
|
||||
def show
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
@@ -56,6 +66,8 @@ class SubmissionsController < ApplicationController
|
||||
create_submissions(@template, submissions_params, params)
|
||||
end
|
||||
|
||||
raise Submissions::CreateFromSubmitters::BaseError, 'Invalid email address' if submissions.blank?
|
||||
|
||||
WebhookUrls.enqueue_events(submissions, 'submission.created')
|
||||
|
||||
Submissions.send_signature_requests(submissions)
|
||||
@@ -83,7 +95,14 @@ class SubmissionsController < ApplicationController
|
||||
I18n.t('submission_has_been_archived')
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path, notice:)
|
||||
if turbo_native_app? && request.format.turbo_stream?
|
||||
flash.now[:notice] = notice
|
||||
|
||||
render turbo_stream: [turbo_stream.remove(@submission), turbo_stream.append_all('body', partial: 'shared/flash')]
|
||||
else
|
||||
redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path,
|
||||
notice:)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -30,5 +30,17 @@ class SubmissionsDashboardController < ApplicationController
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
|
||||
associations: :author).call
|
||||
|
||||
render_infinite_scroll(@pagy, @submissions) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, submissions)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
|
||||
locals: { with_template: true }),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormClickEmailController < ActionController::API
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
if params[:t].present? && params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
|
||||
end
|
||||
|
||||
render json: {}
|
||||
end
|
||||
end
|
||||
@@ -29,7 +29,9 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
if !signature_valid && !current_user_submitter?(last_submitter)
|
||||
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true)
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
if last_submitter.completed_at < TTL.ago
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
@@ -31,24 +31,22 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
Submissions.preload_with_pages(submission)
|
||||
|
||||
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
|
||||
|
||||
@attachments_index = build_attachments_index(submission)
|
||||
|
||||
if current_user && current_user.email == @submitter.email
|
||||
@current_user_data = current_user.as_json(only: %i[first_name last_name], methods: %i[full_name])
|
||||
|
||||
initials = UserConfigs.load_initials(current_user)
|
||||
|
||||
@prefill_initials = Submitters.build_prefill_attachment_data(@submitter, initials) if initials
|
||||
end
|
||||
|
||||
return unless @form_configs[:prefill_signature]
|
||||
|
||||
if (user_signature = UserConfigs.load_signature(current_user))
|
||||
@signature_attachment = ActiveStorage::Attachment.find_or_create_by!(
|
||||
blob_id: user_signature.blob_id,
|
||||
name: 'attachments',
|
||||
record: @submitter
|
||||
)
|
||||
end
|
||||
signature = UserConfigs.load_signature(current_user) ||
|
||||
Submitters::FindRememberedSignature.call(@submitter, params, cookies)
|
||||
|
||||
@signature_attachment ||=
|
||||
Submitters::MaybeAssignDefaultBrowserSignature.call(@submitter, params, cookies, @attachments_index.values)
|
||||
|
||||
@attachments_index[@signature_attachment.uuid] = @signature_attachment if @signature_attachment
|
||||
@prefill_signature = Submitters.build_prefill_attachment_data(@submitter, signature) if signature
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -15,7 +15,8 @@ class SubmitFormDelegateController < ApplicationController
|
||||
@submitter.viewer? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter,
|
||||
current_user,
|
||||
request)
|
||||
request,
|
||||
with_order: true)
|
||||
|
||||
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ class SubmitFormDownloadController < ApplicationController
|
||||
AccountConfig.exists?(account_id: @submitter.account_id,
|
||||
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
|
||||
value: false) ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request,
|
||||
with_order: true)
|
||||
|
||||
last_completed_submitter = @submitter.submission.submitters
|
||||
.where.not(id: @submitter.id)
|
||||
|
||||
@@ -15,7 +15,7 @@ class SubmitFormDrawSignatureController < ApplicationController
|
||||
return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer?
|
||||
|
||||
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true)
|
||||
return redirect_to submit_form_path(@submitter.slug)
|
||||
end
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ class SubmitFormEmail2fasController < ApplicationController
|
||||
def create
|
||||
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||
|
||||
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
|
||||
value = [@submitter.email.downcase.squish, @submitter.slug].join(':')
|
||||
|
||||
if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value)
|
||||
if OtpChallenges.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value, purpose: 'form_email_2fa')
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
|
||||
|
||||
cookies.encrypted[:email_2fa_slug] =
|
||||
|
||||
@@ -17,12 +17,12 @@ class SubmitFormMetadataController < ApplicationController
|
||||
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
|
||||
end
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
|
||||
ActiveRecord::Associations::Preloader.new(records: documents, associations: :blob).call
|
||||
|
||||
text_runs = documents.to_h do |document|
|
||||
[
|
||||
document.uuid,
|
||||
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs
|
||||
DocumentMetadatas.find_or_create_for_document(document, account_id: submission.account_id).text_runs
|
||||
]
|
||||
end
|
||||
|
||||
@@ -39,6 +39,6 @@ class SubmitFormMetadataController < ApplicationController
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at? &&
|
||||
!submitter.account.archived_at? &&
|
||||
Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
Submitters::AuthorizedForForm.call(submitter, current_user, request, with_order: true)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormPrefillAttachmentsController < ActionController::API
|
||||
include ActiveStorage::SetCurrent
|
||||
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
unless can_upload?(@submitter)
|
||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
uuid, slug = ApplicationRecord.signed_id_verifier.verify(params[:prefill_token], purpose: :prefill_attachment)
|
||||
|
||||
raise ActiveRecord::RecordNotFound if slug != @submitter.slug
|
||||
|
||||
source_attachment = ActiveStorage::Attachment.find_by!(uuid:)
|
||||
|
||||
attachment = @submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id)
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_upload?(submitter)
|
||||
!submitter.declined_at? &&
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormUploadController < ActionController::API
|
||||
include ActionController::Cookies
|
||||
include ActiveStorage::SetCurrent
|
||||
|
||||
before_action :load_submitter
|
||||
before_action :authorize_upload!
|
||||
|
||||
COOKIE_STORE_LIMIT = 10
|
||||
|
||||
def create
|
||||
file = params[:file]
|
||||
|
||||
if params[:type].in?(%w[initials signature])
|
||||
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
|
||||
|
||||
if ImageUtils.blank?(image)
|
||||
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
if ImageUtils.error?(image)
|
||||
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} error, try to sign on another device" },
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
|
||||
end
|
||||
|
||||
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
|
||||
|
||||
if params[:remember_signature] == 'true' && @submitter.email.present?
|
||||
cookies.permanent.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
|
||||
end
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
rescue Submitters::MaliciousFileExtension => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
end
|
||||
|
||||
def authorize_upload!
|
||||
unless can_upload?(@submitter)
|
||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
return if Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
|
||||
render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') },
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
def can_upload?(submitter)
|
||||
!submitter.declined_at? &&
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at?
|
||||
end
|
||||
|
||||
def build_new_cookie_signatures_json(submitter, attachment)
|
||||
values =
|
||||
begin
|
||||
JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}')
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
values[submitter.email] = attachment.uuid
|
||||
|
||||
values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT
|
||||
|
||||
values.to_json
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormViewController < ActionController::API
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return head :forbidden if Submitter.signed_id_verifier.verified(params[:v], purpose: :view_form) != @submitter.slug
|
||||
|
||||
@submitter.opened_at = Time.current
|
||||
@submitter.save
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request)
|
||||
|
||||
WebhookUrls.enqueue_events(@submitter, 'form.viewed')
|
||||
|
||||
render json: {}
|
||||
end
|
||||
end
|
||||
@@ -81,9 +81,26 @@ class SubmittersController < ApplicationController
|
||||
|
||||
submitter.name = attrs[:name] if attrs.key?(:name)
|
||||
|
||||
if submitter_changed?(submitter)
|
||||
submitter.submitter_versions.new(slug: submitter.slug, email: submitter.email_was,
|
||||
name: submitter.name_was, phone: submitter.phone_was)
|
||||
|
||||
submitter.slug = SecureRandom.base58(14)
|
||||
submitter.values = Submitters.fetch_values_for_delegate(submitter)
|
||||
end
|
||||
|
||||
submitter
|
||||
end
|
||||
|
||||
def submitter_changed?(submitter)
|
||||
if submitter.email_was.blank? && submitter.phone_was.blank?
|
||||
submitter.name_was.present? && submitter.name_changed?
|
||||
else
|
||||
(submitter.email_was.present? && submitter.email_changed?) ||
|
||||
(submitter.phone_was.present? && submitter.phone_changed?)
|
||||
end
|
||||
end
|
||||
|
||||
def submitter_params
|
||||
params.require(:submitter).permit(:email, :name, :phone).transform_values(&:strip)
|
||||
end
|
||||
|
||||
@@ -6,7 +6,17 @@ class TemplateDocumentsController < ApplicationController
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
|
||||
documents = @template.schema.map do |item|
|
||||
if item['dynamic']
|
||||
dynamic_document = @template.schema_dynamic_documents.find { |e| e.uuid == item['attachment_uuid'] }
|
||||
|
||||
DynamicDocuments::EnsureVersionGenerated.call(dynamic_document).document_attachment
|
||||
else
|
||||
@template.schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
|
||||
end
|
||||
end
|
||||
|
||||
render json: documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -13,10 +13,7 @@ class TemplateFoldersController < ApplicationController
|
||||
.where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])])
|
||||
.preload(:author, :template_accesses)
|
||||
|
||||
@template_folders =
|
||||
@template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
|
||||
|
||||
@template_folders = TemplateFolders.search(@template_folders, params[:q])
|
||||
@template_folders = filter_folders(@template_folder)
|
||||
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
|
||||
|
||||
if @templates.exists?
|
||||
@@ -40,6 +37,8 @@ class TemplateFoldersController < ApplicationController
|
||||
|
||||
@templates = @templates.none
|
||||
end
|
||||
|
||||
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
def edit; end
|
||||
@@ -55,6 +54,27 @@ class TemplateFoldersController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def filter_folders(template_folder)
|
||||
template_folders =
|
||||
template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
|
||||
|
||||
TemplateFolders.search(template_folders, params[:q])
|
||||
end
|
||||
|
||||
def render_infinite_scroll(pagy, templates, template_folders)
|
||||
render_params =
|
||||
if templates.present?
|
||||
{ partial: 'templates/template', collection: templates }
|
||||
else
|
||||
{ partial: 'template_folders/folder', collection: template_folders, as: :folder }
|
||||
end
|
||||
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', **render_params),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def selected_order
|
||||
@selected_order ||=
|
||||
if can?(:manage, :countless)
|
||||
|
||||
@@ -8,7 +8,9 @@ class TemplateSharingsTestingController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
testing_account = Accounts.find_or_create_testing_user(true_user.account).account
|
||||
account = can?(:manage, :tenants_test) ? current_account : true_user.account
|
||||
|
||||
testing_account = Accounts.find_or_create_testing_user(account).account
|
||||
|
||||
if params[:value] == '1'
|
||||
TemplateSharing.create!(ability: :manage, account: testing_account, template: @template)
|
||||
|
||||
@@ -12,13 +12,23 @@ class TemplatesArchivedController < ApplicationController
|
||||
|
||||
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
|
||||
|
||||
return unless params[:q].present? && @templates.blank?
|
||||
if params[:q].present? && @templates.blank?
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions
|
||||
end
|
||||
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions
|
||||
render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, templates)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates,
|
||||
locals: { with_folder: true }),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def load_related_submissions
|
||||
related_submissions =
|
||||
Submission.accessible_by(current_ability)
|
||||
|
||||
@@ -16,7 +16,19 @@ class TemplatesArchivedSubmissionsController < ApplicationController
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
|
||||
|
||||
render_infinite_scroll(@pagy, @submissions, @template) if turbo_infinite_scroll?
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, submissions, template)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
|
||||
locals: { template:, archived: true }),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -21,6 +21,8 @@ class TemplatesController < ApplicationController
|
||||
|
||||
@pagy, @submissions =
|
||||
pagy_auto(submissions.select_for_list.preload(:template_accesses, submitters: :start_form_submission_events))
|
||||
|
||||
render_infinite_scroll(@pagy, @submissions, @template) if turbo_infinite_scroll?
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
@@ -81,11 +83,23 @@ class TemplatesController < ApplicationController
|
||||
I18n.t('template_has_been_archived')
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: root_path, notice:)
|
||||
if @template.destroyed? && turbo_native_app?
|
||||
redirect_to templates_archived_index_path, notice:
|
||||
else
|
||||
redirect_back(fallback_location: root_path, notice:)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, submissions, template)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
|
||||
locals: { template:, archived: template.archived_at? }),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
:name,
|
||||
|
||||
@@ -43,10 +43,26 @@ class TemplatesDashboardController < ApplicationController
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions
|
||||
end
|
||||
end
|
||||
|
||||
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, templates, template_folders)
|
||||
render_params =
|
||||
if templates.present?
|
||||
{ partial: 'templates/template', collection: templates }
|
||||
else
|
||||
{ partial: 'template_folders/folder', collection: template_folders, as: :folder }
|
||||
end
|
||||
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', **render_params),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def load_templates(templates, folders_count, show_shared_inline: false)
|
||||
templates = templates.preload(:author, :template_accesses)
|
||||
|
||||
@@ -94,16 +110,16 @@ class TemplatesDashboardController < ApplicationController
|
||||
|
||||
pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0)
|
||||
|
||||
pagy = Pagy::Offset.new(count: folders_count + pinned_count,
|
||||
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
|
||||
limit: FOLDERS_PER_PAGE,
|
||||
raise_range_error: true)
|
||||
pagy, = pagy(template_folders, count: folders_count + pinned_count,
|
||||
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
|
||||
limit: FOLDERS_PER_PAGE,
|
||||
raise_range_error: true)
|
||||
|
||||
show_default_folder = show_default_in_grid && pagy.page == 1
|
||||
show_shared_folder = show_shared_in_grid && pagy.page == 1
|
||||
|
||||
folder_offset = pagy.page == 1 ? 0 : pagy.offset - pinned_count
|
||||
folder_limit = pagy.page == 1 ? FOLDERS_PER_PAGE - pinned_count : FOLDERS_PER_PAGE
|
||||
folder_limit = pagy.page == 1 ? pagy.limit - pinned_count : pagy.limit
|
||||
|
||||
template_folders = template_folders.offset(folder_offset).limit(folder_limit)
|
||||
|
||||
|
||||
@@ -19,13 +19,22 @@ class TemplatesSharedController < ApplicationController
|
||||
|
||||
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
|
||||
|
||||
return unless params[:q].present? && @templates.blank?
|
||||
if params[:q].present? && @templates.blank?
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
|
||||
end
|
||||
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
|
||||
render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, templates)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def load_related_submissions(is_archived:)
|
||||
shared_templates = Templates.shared(current_user)
|
||||
shared_templates = is_archived ? shared_templates.archived : shared_templates.active
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
class TestingApiSettingsController < ApplicationController
|
||||
def index
|
||||
raise CanCan::AccessDenied unless current_account.testing?
|
||||
|
||||
authorize!(:manage, current_user.access_token)
|
||||
|
||||
@webhook_url = current_account.webhook_urls.first_or_initialize
|
||||
|
||||
@@ -11,11 +11,9 @@ class UserInitialsController < ApplicationController
|
||||
|
||||
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials') if file.blank?
|
||||
|
||||
extension = File.extname(file.original_filename).delete_prefix('.').downcase
|
||||
extension = FilenameUtils.dangerous_extension(file.original_filename)
|
||||
|
||||
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
|
||||
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
|
||||
end
|
||||
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed." if extension
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
|
||||
filename: file.original_filename,
|
||||
|
||||
@@ -11,11 +11,9 @@ class UserSignaturesController < ApplicationController
|
||||
|
||||
return redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature') if file.blank?
|
||||
|
||||
extension = File.extname(file.original_filename).delete_prefix('.').downcase
|
||||
extension = FilenameUtils.dangerous_extension(file.original_filename)
|
||||
|
||||
if Submitters::DANGEROUS_EXTENSIONS.include?(extension)
|
||||
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed."
|
||||
end
|
||||
raise Submitters::MaliciousFileExtension, "File type '.#{extension}' is not allowed." if extension
|
||||
|
||||
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
|
||||
filename: file.original_filename,
|
||||
|
||||
@@ -20,7 +20,7 @@ class UsersController < ApplicationController
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
@pagy, @users = pagy(@users)
|
||||
@pagy, @users = pagy(@users, limit: turbo_native_app? ? 1_000 : Pagy::OPTIONS[:limit])
|
||||
end
|
||||
|
||||
if current_ability.can?(:manage, current_account)
|
||||
|
||||
+196
-38
@@ -1,5 +1,5 @@
|
||||
import '@hotwired/turbo'
|
||||
import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascript/turbo/fetch_requests'
|
||||
import '@hotwired/turbo-rails'
|
||||
import './native'
|
||||
|
||||
import { createApp, reactive } from 'vue'
|
||||
import TemplateBuilder from './template_builder/builder'
|
||||
@@ -9,6 +9,15 @@ import ToggleVisible from './elements/toggle_visible'
|
||||
import ToggleCookies from './elements/toggle_cookies'
|
||||
import DisableHidden from './elements/disable_hidden'
|
||||
import TurboModal from './elements/turbo_modal'
|
||||
import NativeAction from './elements/native_action'
|
||||
import NativeMenu from './elements/native_menu'
|
||||
import NativeSearch from './elements/native_search'
|
||||
import NativeModal from './elements/native_modal'
|
||||
import NativeEvent from './elements/native_event'
|
||||
import NativeUpload from './elements/native_upload'
|
||||
import FormPrompt from './elements/form_prompt'
|
||||
import InfiniteScroll from './elements/infinite_scroll'
|
||||
import ModalButton from './elements/modal_button'
|
||||
import FileDropzone from './elements/file_dropzone'
|
||||
import MenuActive from './elements/menu_active'
|
||||
import ClipboardCopy from './elements/clipboard_copy'
|
||||
@@ -26,6 +35,9 @@ import PromptPassword from './elements/prompt_password'
|
||||
import EmailsTextarea from './elements/emails_textarea'
|
||||
import ToggleSubmit from './elements/toggle_submit'
|
||||
import ToggleOnSubmit from './elements/toggle_on_submit'
|
||||
import ToggleOnChecked from './elements/toggle_on_checked'
|
||||
import ToggleSendTitle from './elements/toggle_send_title'
|
||||
import AppendTemplate from './elements/append_template'
|
||||
import CheckOnClick from './elements/check_on_click'
|
||||
import PasswordInput from './elements/password_input'
|
||||
import SearchInput from './elements/search_input'
|
||||
@@ -59,10 +71,8 @@ import ConfirmUpload from './elements/confirm_upload'
|
||||
import ScrollFade from './elements/scroll_fade'
|
||||
import OpenModalMobile from './elements/open_modal_mobile'
|
||||
import HistoryBack from './elements/history_back'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
TurboInstantClick.start()
|
||||
import DatePlaceholder from './elements/date_placeholder'
|
||||
import FlashMessage from './elements/flash_message'
|
||||
|
||||
document.addEventListener('turbo:before-cache', () => {
|
||||
window.flash?.remove()
|
||||
@@ -74,10 +84,18 @@ document.addEventListener('keyup', (e) => {
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
||||
document.addEventListener('turbo:before-fetch-request', (event) => {
|
||||
event.detail.fetchOptions.headers['X-Turbo'] = 'true'
|
||||
document.addEventListener('turbo:morph', () => {
|
||||
if (document.activeElement?.closest('.dropdown')) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('turbo:before-fetch-response', (event) => {
|
||||
if (event.detail.fetchResponse.header('content-disposition')?.includes('attachment')) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('turbo:submit-end', async (event) => {
|
||||
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
|
||||
|
||||
@@ -85,11 +103,23 @@ document.addEventListener('turbo:submit-end', async (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
const filename = decodeURIComponent(resp.headers.get('content-disposition').split('"')[1])
|
||||
const download = window.webkit?.messageHandlers?.download
|
||||
|
||||
if (download) {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = () => download.postMessage({ name: filename, data: reader.result.split(',')[1] })
|
||||
reader.readAsDataURL(await resp.blob())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(await resp.blob())
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.setAttribute('download', decodeURIComponent(resp.headers.get('content-disposition').split('"')[1]))
|
||||
link.setAttribute('download', filename)
|
||||
|
||||
document.body.appendChild(link)
|
||||
|
||||
@@ -105,6 +135,15 @@ const safeRegisterElement = (name, element, options = {}) => !window.customEleme
|
||||
safeRegisterElement('toggle-visible', ToggleVisible)
|
||||
safeRegisterElement('disable-hidden', DisableHidden)
|
||||
safeRegisterElement('turbo-modal', TurboModal)
|
||||
safeRegisterElement('native-action', NativeAction)
|
||||
safeRegisterElement('native-menu', NativeMenu)
|
||||
safeRegisterElement('native-search', NativeSearch)
|
||||
safeRegisterElement('native-modal', NativeModal)
|
||||
safeRegisterElement('native-event', NativeEvent)
|
||||
safeRegisterElement('native-upload', NativeUpload)
|
||||
safeRegisterElement('form-prompt', FormPrompt)
|
||||
safeRegisterElement('infinite-scroll', InfiniteScroll)
|
||||
safeRegisterElement('modal-button', ModalButton)
|
||||
safeRegisterElement('file-dropzone', FileDropzone)
|
||||
safeRegisterElement('menu-active', MenuActive)
|
||||
safeRegisterElement('clipboard-copy', ClipboardCopy)
|
||||
@@ -123,6 +162,9 @@ safeRegisterElement('emails-textarea', EmailsTextarea)
|
||||
safeRegisterElement('toggle-cookies', ToggleCookies)
|
||||
safeRegisterElement('toggle-submit', ToggleSubmit)
|
||||
safeRegisterElement('toggle-on-submit', ToggleOnSubmit)
|
||||
safeRegisterElement('toggle-on-checked', ToggleOnChecked)
|
||||
safeRegisterElement('toggle-send-title', ToggleSendTitle)
|
||||
safeRegisterElement('append-template', AppendTemplate)
|
||||
safeRegisterElement('password-input', PasswordInput)
|
||||
safeRegisterElement('search-input', SearchInput)
|
||||
safeRegisterElement('toggle-attribute', ToggleAttribute)
|
||||
@@ -156,22 +198,37 @@ safeRegisterElement('confirm-upload', ConfirmUpload)
|
||||
safeRegisterElement('scroll-fade', ScrollFade)
|
||||
safeRegisterElement('open-modal-mobile', OpenModalMobile)
|
||||
safeRegisterElement('history-back', HistoryBack)
|
||||
safeRegisterElement('date-placeholder', DatePlaceholder)
|
||||
safeRegisterElement('flash-message', FlashMessage)
|
||||
|
||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||
document.addEventListener('template-builder:update', this.onSheetSubmit)
|
||||
document.addEventListener('template-builder:sync', this.onSync)
|
||||
document.addEventListener('template-builder:scroll-to', this.onScrollTo)
|
||||
document.addEventListener('template-builder:select-field', this.onSelectField)
|
||||
document.addEventListener('template-builder:select-submitter', this.onSelectSubmitter)
|
||||
document.addEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields)
|
||||
document.addEventListener('template-builder:draw-field', this.onDrawField)
|
||||
document.addEventListener('native:builder-view', this.onBuilderView)
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.appElem = document.createElement('div')
|
||||
|
||||
this.appElem.classList.add('md:h-screen')
|
||||
|
||||
if (this.dataset.nativeView) {
|
||||
this.appElem.classList.add('min-w-0')
|
||||
}
|
||||
|
||||
const template = reactive(JSON.parse(this.dataset.template))
|
||||
|
||||
this.app = createApp(TemplateBuilder, {
|
||||
template,
|
||||
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
|
||||
dateFormats: JSON.parse(this.dataset.dateFormats || '[]'),
|
||||
autosave: this.dataset.autosave !== 'false',
|
||||
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
|
||||
backgroundColor: '#faf7f5',
|
||||
locale: this.dataset.locale,
|
||||
@@ -203,7 +260,9 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withDownload: true,
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||
showTourStartForm: this.dataset.showTourStartForm === 'true'
|
||||
showTourStartForm: this.dataset.showTourStartForm === 'true',
|
||||
nativePlatform: this.dataset.nativePlatform,
|
||||
nativeView: this.dataset.nativeView
|
||||
})
|
||||
|
||||
this.component = this.app.mount(this.appElem)
|
||||
@@ -211,39 +270,130 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
this.appendChild(this.appElem)
|
||||
}
|
||||
|
||||
onSubmit = (e) => {
|
||||
if (e.detail.success) {
|
||||
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
|
||||
e.detail.fetchResponse.response.json().then((data) => {
|
||||
this.component.template.submitters = data.submitters
|
||||
})
|
||||
onSubmit = async (e) => {
|
||||
const form = e.detail.formSubmission?.formElement
|
||||
|
||||
if (!e.detail.success || !form) return
|
||||
|
||||
const data = {}
|
||||
|
||||
new FormData(form).forEach((value, key) => { data[key] = value })
|
||||
|
||||
this.applySubmission(form.id, form.action, await e.detail.fetchResponse.responseText, data)
|
||||
}
|
||||
|
||||
onSheetSubmit = (e) => {
|
||||
this.applySubmission(e.detail.form, e.detail.action, e.detail.body, {})
|
||||
this.component.syncNative()
|
||||
}
|
||||
|
||||
onSync = (e) => {
|
||||
const { schema, documents, fields, submitters, pendingFieldAttachmentUuids } = e.detail
|
||||
const selectedAreas = this.component.selectedAreasRef.value.map((area) => {
|
||||
const field = this.component.template.fields.find((f) => f.areas?.includes(area))
|
||||
|
||||
return field && { uuid: field.uuid, index: field.areas.indexOf(area) }
|
||||
})
|
||||
const selectedSubmitterUuid = this.component.selectedSubmitter?.uuid
|
||||
|
||||
Object.assign(this.component.template, { schema, documents, fields, submitters })
|
||||
|
||||
this.component.selectedAreasRef.value = selectedAreas.map((item) => item && fields.find((f) => f.uuid === item.uuid)?.areas?.[item.index]).filter(Boolean)
|
||||
this.component.selectedSubmitter = submitters.find((s) => s.uuid === selectedSubmitterUuid) || submitters[0]
|
||||
this.component.pendingFieldAttachmentUuids = pendingFieldAttachmentUuids
|
||||
}
|
||||
|
||||
onScrollTo = (e) => {
|
||||
this.component.scrollIntoDocument(e.detail)
|
||||
}
|
||||
|
||||
onSyncCustomFields = (e) => {
|
||||
this.component.customFields.splice(0, this.component.customFields.length, ...e.detail)
|
||||
}
|
||||
|
||||
onSelectSubmitter = (e) => {
|
||||
const submitter = this.component.template.submitters.find((s) => s.uuid === e.detail.uuid)
|
||||
|
||||
if (submitter) this.component.selectedSubmitter = submitter
|
||||
}
|
||||
|
||||
onSelectField = (e) => {
|
||||
const { uuid, attachment_uuid: attachmentUuid, page, x, y } = e.detail
|
||||
const field = this.component.template.fields.find((f) => f.uuid === uuid)
|
||||
const areas = field?.areas || []
|
||||
const area = areas.find((a) => a.attachment_uuid === attachmentUuid && a.page === page && a.x === x && a.y === y) || areas[0]
|
||||
|
||||
if (area) this.component.scrollToArea(area)
|
||||
}
|
||||
|
||||
onDrawField = (e) => {
|
||||
const { uuid, type, option, custom_field_uuid: customFieldUuid } = e.detail
|
||||
|
||||
if (customFieldUuid) {
|
||||
const field = this.component.customFields.find((f) => f.uuid === customFieldUuid)
|
||||
|
||||
if (field) {
|
||||
this.component.drawCustomField = field
|
||||
this.component.showDrawField = true
|
||||
}
|
||||
} else if (uuid) {
|
||||
const field = this.component.template.fields.find((f) => f.uuid === uuid)
|
||||
|
||||
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
|
||||
e.detail.fetchResponse.response.text().then((data) => {
|
||||
const doc = new DOMParser().parseFromString(data, 'text/html')
|
||||
const fragment = doc.querySelector('turbo-stream template').content
|
||||
|
||||
const prefillableUuidsIndex = {}
|
||||
|
||||
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
|
||||
prefillableUuidsIndex[field.value] = true
|
||||
})
|
||||
|
||||
this.component.template.fields.forEach((field) => {
|
||||
if (prefillableUuidsIndex[field.uuid]) {
|
||||
field.prefillable = true
|
||||
field.readonly = true
|
||||
} else if (field.prefillable) {
|
||||
delete field.prefillable
|
||||
delete field.readonly
|
||||
}
|
||||
})
|
||||
})
|
||||
if (field) {
|
||||
this.component.drawField = field
|
||||
this.component.drawOption = option || null
|
||||
}
|
||||
} else if (type) {
|
||||
this.component.startFieldDraw({ type })
|
||||
}
|
||||
}
|
||||
|
||||
applySubmission (formId, action, body, data) {
|
||||
if (formId === 'submitters_form') {
|
||||
this.component.template.submitters = JSON.parse(body).submitters
|
||||
} else if (action.endsWith('/prefillable_fields')) {
|
||||
const doc = new DOMParser().parseFromString(body, 'text/html')
|
||||
const fragment = doc.querySelector('turbo-stream template').content
|
||||
|
||||
const prefillableUuidsIndex = {}
|
||||
|
||||
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
|
||||
prefillableUuidsIndex[field.value] = true
|
||||
})
|
||||
|
||||
this.component.template.fields.forEach((field) => {
|
||||
if (prefillableUuidsIndex[field.uuid]) {
|
||||
field.prefillable = true
|
||||
field.readonly = true
|
||||
} else if (field.prefillable) {
|
||||
delete field.prefillable
|
||||
delete field.readonly
|
||||
}
|
||||
})
|
||||
} else if (data['template[name]']) {
|
||||
this.component.template.name = data['template[name]']
|
||||
|
||||
document.title = data['template[name]']
|
||||
}
|
||||
}
|
||||
|
||||
onBuilderView = (event) => {
|
||||
const root = document.documentElement.cloneNode(true)
|
||||
const builder = root.querySelector('template-builder')
|
||||
const style = document.createElement('style')
|
||||
|
||||
builder.innerHTML = ''
|
||||
builder.setAttribute('data-native-view', event.detail.view)
|
||||
|
||||
root.querySelector('meta[name="viewport"]')?.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no')
|
||||
|
||||
style.textContent = 'html { overflow-x: hidden; overscroll-behavior-x: none; } html, body { max-width: 100%; } body { -webkit-user-select: none; user-select: none; } input, textarea, [contenteditable] { -webkit-user-select: text; user-select: text; }'
|
||||
|
||||
root.querySelector('head').appendChild(style)
|
||||
|
||||
event.detail.result = '<!DOCTYPE html>' + root.outerHTML
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
this.app?.unmount()
|
||||
this.appElem?.remove()
|
||||
@@ -251,6 +401,14 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
||||
document.removeEventListener('template-builder:update', this.onSheetSubmit)
|
||||
document.removeEventListener('template-builder:sync', this.onSync)
|
||||
document.removeEventListener('template-builder:scroll-to', this.onScrollTo)
|
||||
document.removeEventListener('template-builder:select-field', this.onSelectField)
|
||||
document.removeEventListener('template-builder:select-submitter', this.onSelectSubmitter)
|
||||
document.removeEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields)
|
||||
document.removeEventListener('template-builder:draw-field', this.onDrawField)
|
||||
document.removeEventListener('native:builder-view', this.onBuilderView)
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.app?.unmount()
|
||||
|
||||
@@ -166,3 +166,71 @@ button[disabled] .enabled, button.btn-disabled .enabled {
|
||||
markdown-editor [contenteditable] p {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
native-action {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.table-stack,
|
||||
.table-stack tbody,
|
||||
.table-stack tr,
|
||||
.table-stack td {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.table-stack,
|
||||
div:has(> .table-stack) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.table-stack thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-stack tr {
|
||||
@apply bg-base-200 rounded-2xl py-3 mb-3 border-0;
|
||||
}
|
||||
|
||||
.table-stack td {
|
||||
@apply px-4 py-1;
|
||||
}
|
||||
|
||||
.table-stack td[data-label] {
|
||||
@apply flow-root text-right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.table-stack td[data-label]::before {
|
||||
content: attr(data-label);
|
||||
@apply float-left mr-4 text-sm leading-6 text-base-content/60;
|
||||
}
|
||||
|
||||
.table-stack td:first-child {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.table-stack td:last-child {
|
||||
@apply grid grid-cols-2 gap-2 pt-3;
|
||||
}
|
||||
|
||||
.table-stack td:last-child:has(> .dropdown) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
|
||||
}
|
||||
|
||||
.table-stack td:last-child:not(:has(*)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-stack td:last-child > * {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.table-stack td:last-child > :last-child:nth-child(odd):not(.dropdown) {
|
||||
@apply col-span-2;
|
||||
}
|
||||
|
||||
.table-stack td:last-child .btn {
|
||||
@apply btn-sm w-full;
|
||||
}
|
||||
}
|
||||
@@ -126,11 +126,10 @@ window.customElements.define('draw-signature', class extends HTMLElement {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('file', file)
|
||||
formData.append('submitter_slug', this.dataset.slug)
|
||||
formData.append('name', 'attachments')
|
||||
formData.append('remember_signature', 'true')
|
||||
|
||||
return fetch('/api/attachments', {
|
||||
return fetch(`/s/${this.dataset.slug}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(resp => resp.json())
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
onClick = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
const target = document.getElementById(this.dataset.targetId)
|
||||
const item = this.querySelector('template').content.firstElementChild.cloneNode(true)
|
||||
|
||||
item.querySelector('a').addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
item.remove()
|
||||
|
||||
target.classList.toggle('hidden', !target.childElementCount)
|
||||
})
|
||||
|
||||
target.append(item)
|
||||
target.classList.remove('hidden')
|
||||
|
||||
item.querySelector('input').focus()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.id = `autoresize-textarea-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.resize()
|
||||
|
||||
this.textarea.addEventListener('input', () => this.resize())
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.id = `bar-chart-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.chartLabels = JSON.parse(this.dataset.labels || '[]')
|
||||
this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
|
||||
|
||||
|
||||
@@ -4,9 +4,18 @@ export default class extends HTMLElement {
|
||||
|
||||
this.addEventListener('click', (e) => {
|
||||
const text = this.dataset.text || this.innerText.trim()
|
||||
const download = window.webkit?.messageHandlers?.download
|
||||
|
||||
if (download && /^https?:\/\//.test(text)) {
|
||||
e.preventDefault()
|
||||
|
||||
return download.postMessage({ url: text })
|
||||
}
|
||||
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text)
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
window.webkit?.messageHandlers?.flash?.postMessage({ style: 'notice', message: this.dataset.copied })
|
||||
})
|
||||
} else {
|
||||
if (e.target.tagName !== 'INPUT') {
|
||||
alert(`Clipboard not available. Make sure you're using https://\nCopy text: ${text}`)
|
||||
|
||||
@@ -59,10 +59,10 @@ export async function convertImagesInInput (input) {
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const input = this.querySelector('input[type="file"]')
|
||||
const form = input.form
|
||||
this.addEventListener('change', async (e) => {
|
||||
const input = e.target
|
||||
const form = input.form
|
||||
|
||||
input.addEventListener('change', async () => {
|
||||
await convertImagesInInput(input)
|
||||
|
||||
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
|
||||
|
||||
@@ -26,17 +26,41 @@ export default targetable(class extends HTMLElement {
|
||||
|
||||
window.addEventListener('dragleave', this.onWindowDragleave)
|
||||
|
||||
this.fileDropzone?.addEventListener('drop', this.onDropFile)
|
||||
this.folderDropzone?.addEventListener('drop', this.onDropNewFolder)
|
||||
this.addEventListener('drop', this.onDrop)
|
||||
this.addEventListener('dragstart', this.onDragstart)
|
||||
this.addEventListener('dragover', this.onDragover)
|
||||
this.addEventListener('dragleave', this.onDragleave)
|
||||
}
|
||||
|
||||
this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el)))
|
||||
this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate))
|
||||
this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart))
|
||||
dropTarget (e) {
|
||||
return e.target.closest([
|
||||
'[data-target="dashboard-dropzone.fileDropzone"]',
|
||||
'[data-target="dashboard-dropzone.folderDropzone"]',
|
||||
'[data-targets="dashboard-dropzone.folderCards"]',
|
||||
'[data-targets="dashboard-dropzone.templateCards"]'
|
||||
].join(', '))
|
||||
}
|
||||
|
||||
return [this.fileDropzone, this.folderDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => {
|
||||
el?.addEventListener('dragover', this.onDragover)
|
||||
el?.addEventListener('dragleave', this.onDragleave)
|
||||
})
|
||||
onDrop = (e) => {
|
||||
const el = this.dropTarget(e)
|
||||
|
||||
if (!el) return
|
||||
|
||||
if (el === this.fileDropzone) {
|
||||
this.onDropFile(e)
|
||||
} else if (el === this.folderDropzone) {
|
||||
this.onDropNewFolder(e)
|
||||
} else if (el.dataset.targets === 'dashboard-dropzone.folderCards') {
|
||||
this.onDropFolder(e, el)
|
||||
} else {
|
||||
this.onDropTemplate(e, el)
|
||||
}
|
||||
}
|
||||
|
||||
onDragstart = (e) => {
|
||||
if (e.target.closest('[data-targets="dashboard-dropzone.templateCards"]')) {
|
||||
this.onTemplateDragStart(e)
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
@@ -122,13 +146,13 @@ export default targetable(class extends HTMLElement {
|
||||
}
|
||||
}).finally(() => {
|
||||
window.Turbo.cache.clear()
|
||||
window.Turbo.visit(location.href)
|
||||
window.Turbo.visit(location.href, { action: 'replace' })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDropTemplate = (e) => {
|
||||
onDropTemplate = (e, el) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.dataTransfer.files.length) {
|
||||
@@ -136,10 +160,10 @@ export default targetable(class extends HTMLElement {
|
||||
loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute')
|
||||
loading.innerHTML = loadingIconHtml
|
||||
|
||||
e.target.appendChild(loading)
|
||||
e.target.classList.add('opacity-50')
|
||||
el.appendChild(loading)
|
||||
el.classList.add('opacity-50')
|
||||
|
||||
const id = e.target.href.split('/').pop()
|
||||
const id = el.href.split('/').pop()
|
||||
|
||||
this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`)
|
||||
}
|
||||
@@ -173,16 +197,20 @@ export default targetable(class extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
onDragover (e) {
|
||||
if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') {
|
||||
this.style.backgroundColor = '#F7F3F0'
|
||||
onDragover = (e) => {
|
||||
const el = this.dropTarget(e)
|
||||
|
||||
if (this.classList.contains('before:border-base-300')) {
|
||||
this.classList.remove('before:border-base-300')
|
||||
this.classList.add('before:border-base-content/30')
|
||||
} else if (this.classList.contains('border-base-300')) {
|
||||
this.classList.remove('border-base-300')
|
||||
this.classList.add('border-base-content/30')
|
||||
if (!el) return
|
||||
|
||||
if (e.dataTransfer?.types?.includes('Files') || el.dataset.targets !== 'dashboard-dropzone.templateCards') {
|
||||
el.style.backgroundColor = '#F7F3F0'
|
||||
|
||||
if (el.classList.contains('before:border-base-300')) {
|
||||
el.classList.remove('before:border-base-300')
|
||||
el.classList.add('before:border-base-content/30')
|
||||
} else if (el.classList.contains('border-base-300')) {
|
||||
el.classList.remove('border-base-300')
|
||||
el.classList.add('border-base-content/30')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,15 +233,19 @@ export default targetable(class extends HTMLElement {
|
||||
a.remove()
|
||||
}
|
||||
|
||||
onDragleave () {
|
||||
this.style.backgroundColor = null
|
||||
onDragleave = (e) => {
|
||||
const el = this.dropTarget(e)
|
||||
|
||||
if (this.classList.contains('before:border-base-content/30')) {
|
||||
this.classList.remove('before:border-base-content/30')
|
||||
this.classList.add('before:border-base-300')
|
||||
} else if (this.classList.contains('border-base-content/30')) {
|
||||
this.classList.remove('border-base-content/30')
|
||||
this.classList.add('border-base-300')
|
||||
if (!el) return
|
||||
|
||||
el.style.backgroundColor = null
|
||||
|
||||
if (el.classList.contains('before:border-base-content/30')) {
|
||||
el.classList.remove('before:border-base-content/30')
|
||||
el.classList.add('before:border-base-300')
|
||||
} else if (el.classList.contains('border-base-content/30')) {
|
||||
el.classList.remove('border-base-content/30')
|
||||
el.classList.add('border-base-300')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const input = this.querySelector('input')
|
||||
const placeholder = this.querySelector('span')
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
placeholder.classList.toggle('hidden', !!input.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,18 @@ export default class extends HTMLElement {
|
||||
static observedAttributes = ['class']
|
||||
|
||||
connectedCallback () {
|
||||
this.addEventListener('turbo:morph-element', this.onMorph)
|
||||
|
||||
this.trigger()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('turbo:morph-element', this.onMorph)
|
||||
}
|
||||
|
||||
onMorph = (event) => {
|
||||
if (event.target !== this) return
|
||||
|
||||
this.trigger()
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,16 @@ export default targetable(class extends HTMLElement {
|
||||
downloadFiles () {
|
||||
if (!this.dataset.src) return
|
||||
|
||||
const download = window.webkit?.messageHandlers?.download
|
||||
|
||||
if (download) {
|
||||
this.toggleState()
|
||||
|
||||
document.addEventListener('native:download-end', () => this.toggleState(), { once: true })
|
||||
|
||||
return download.postMessage({ src: this.dataset.src })
|
||||
}
|
||||
|
||||
this.toggleState()
|
||||
|
||||
fetch(this.dataset.src).then(async (response) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { actionable } from '@github/catalyst/lib/actionable'
|
||||
import { targets, targetable } from '@github/catalyst/lib/targetable'
|
||||
|
||||
export default actionable(targetable(class extends HTMLElement {
|
||||
static [targets.static] = ['items']
|
||||
static [targets.static] = ['items', 'groups']
|
||||
|
||||
addItem (e) {
|
||||
e.preventDefault()
|
||||
@@ -24,11 +24,28 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
duplicateItem.querySelectorAll('a.hidden').forEach((button) => button.classList.toggle('hidden'))
|
||||
|
||||
originalItem.parentNode.append(duplicateItem)
|
||||
|
||||
this.toggleMultipleClasses()
|
||||
|
||||
duplicateItem.querySelector("input:not([type='hidden'])")?.focus()
|
||||
}
|
||||
|
||||
removeItem (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.items.find((item) => item.contains(e.target))?.remove()
|
||||
|
||||
this.toggleMultipleClasses()
|
||||
}
|
||||
|
||||
toggleMultipleClasses () {
|
||||
const isMultiple = this.items.length > 1
|
||||
const elements = [...this.items, ...this.groups]
|
||||
|
||||
elements.forEach((element) => {
|
||||
const classes = element.dataset.multipleClasses || this.dataset.multipleClasses
|
||||
|
||||
classes?.split(' ').forEach((cls) => element.classList.toggle(cls, isMultiple))
|
||||
})
|
||||
}
|
||||
}))
|
||||
@@ -39,6 +39,8 @@ export default targetable(class extends HTMLElement {
|
||||
]
|
||||
|
||||
connectedCallback () {
|
||||
this.id = `email-editor-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.mount()
|
||||
|
||||
if (this.input.value) {
|
||||
|
||||
@@ -35,7 +35,7 @@ export default class extends HTMLElement {
|
||||
counter.innerText = `${count} / ${this.dataset.limit}`
|
||||
}
|
||||
|
||||
if (this.dataset.bulkEnabled !== 'true') {
|
||||
if (this.dataset.bulkEnabled !== 'true' && !/Hotwire Native iOS/.test(navigator.userAgent)) {
|
||||
if (!bulkMessage) {
|
||||
bulkMessage = document.createElement('span')
|
||||
|
||||
|
||||
@@ -14,6 +14,15 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
this.addEventListener('dragover', (e) => e.preventDefault())
|
||||
this.addEventListener('drop', this.onDrop)
|
||||
document.addEventListener('turbo:submit-end', this.toggleLoading)
|
||||
|
||||
if (this.area) {
|
||||
this.initArea()
|
||||
} else {
|
||||
queueMicrotask(() => this.initArea())
|
||||
}
|
||||
}
|
||||
|
||||
initArea () {
|
||||
this.area?.addEventListener('dragover', this.onDragover)
|
||||
this.area?.addEventListener('dragleave', this.onDragleave)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('turbo:morph-element', this.onMorph)
|
||||
|
||||
this.show()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('turbo:morph-element', this.onMorph)
|
||||
|
||||
clearTimeout(this.closeTimeout)
|
||||
}
|
||||
|
||||
onMorph = (event) => {
|
||||
if (event.target !== this) return
|
||||
|
||||
this.show()
|
||||
}
|
||||
|
||||
show () {
|
||||
clearTimeout(this.closeTimeout)
|
||||
|
||||
this.hideAnimation?.cancel()
|
||||
|
||||
this.animate([
|
||||
{ transform: 'translateY(-100%)', opacity: 0 },
|
||||
{ transform: 'translateY(0)', opacity: 1 }
|
||||
], { duration: 200, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' })
|
||||
|
||||
this.closeTimeout = setTimeout(this.close, 2000)
|
||||
}
|
||||
|
||||
close = () => {
|
||||
clearTimeout(this.closeTimeout)
|
||||
|
||||
this.hideAnimation = this.animate([
|
||||
{ transform: 'translateY(0)', opacity: 1 },
|
||||
{ transform: 'translateY(-100%)', opacity: 0 }
|
||||
], { duration: 150, easing: 'ease-in', fill: 'forwards' })
|
||||
|
||||
this.hideAnimation.onfinish = () => this.remove()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', this.prompt)
|
||||
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('click', this.prompt)
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.querySelector('form')
|
||||
}
|
||||
|
||||
prompt = () => {
|
||||
const label = this.form.querySelector('label')
|
||||
const input = this.form.querySelector(`#${CSS.escape(label.htmlFor)}`)
|
||||
const value = window.prompt(label.textContent.trim(), input.value)?.trim()
|
||||
|
||||
if (!value || value === input.value) return
|
||||
|
||||
input.value = value
|
||||
|
||||
this.form.requestSubmit()
|
||||
}
|
||||
|
||||
onSubmitEnd = (e) => {
|
||||
if (e.detail.success && e.detail.formSubmission?.formElement === this.form && this.dataset.refresh !== 'false') {
|
||||
window.Turbo.visit(window.location.href, { action: 'replace' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,7 +353,7 @@ function buildExtensions ({ Node, Mark, Extension, Plugin, Decoration, Decoratio
|
||||
|
||||
const buildDecorations = (doc) => {
|
||||
const decorations = []
|
||||
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
|
||||
const regex = /\{\{?[^{}\n]+\}\}?/g
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) return
|
||||
@@ -458,6 +458,8 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
async connectedCallback () {
|
||||
if (!this.textarea || !this.editorElement) return
|
||||
|
||||
this.id = `html-editor-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.textarea.style.display = 'none'
|
||||
this.adjustShortcutsForPlatform()
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { renderStreamMessage, session } from '@hotwired/turbo'
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (this.dataset.limit) this.replaceLimitParam()
|
||||
|
||||
if (!this.dataset.src) {
|
||||
this.remove()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
this.load()
|
||||
}
|
||||
}, { rootMargin: '400px' })
|
||||
|
||||
this.observer.observe(this)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.observer?.disconnect()
|
||||
}
|
||||
|
||||
replaceLimitParam () {
|
||||
const url = new URL(window.location.href)
|
||||
|
||||
url.searchParams.set('limit', this.dataset.limit)
|
||||
|
||||
session.history.replace(url, session.history.restorationIdentifier)
|
||||
session.view.lastRenderedLocation = url
|
||||
}
|
||||
|
||||
load () {
|
||||
if (this.loading || !this.dataset.src) return
|
||||
|
||||
this.loading = true
|
||||
|
||||
fetch(this.dataset.src, {
|
||||
headers: { 'X-Turbo-Infinite-Scroll': 'true' }
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) return
|
||||
|
||||
delete this.dataset.src
|
||||
|
||||
renderStreamMessage(await response.text())
|
||||
}).finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,8 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
async connectedCallback () {
|
||||
if (!this.textarea || !this.editorElement) return
|
||||
|
||||
this.id = `markdown-editor-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.textarea.style.display = 'none'
|
||||
this.adjustShortcutsForPlatform()
|
||||
|
||||
@@ -150,7 +152,7 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
|
||||
const buildDecorations = (doc) => {
|
||||
const decorations = []
|
||||
const regex = /\{\{?[a-zA-Z0-9_.-]+\}\}?/g
|
||||
const regex = /\{\{?[^{}\n]+\}\}?/g
|
||||
|
||||
doc.descendants((node, pos) => {
|
||||
if (!node.isText) return
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const maskedToken = this.input.value
|
||||
|
||||
this.input.addEventListener('focus', () => {
|
||||
this.maskedToken = this.input.value
|
||||
this.input.value = this.dataset.token
|
||||
this.input.select()
|
||||
})
|
||||
|
||||
this.input.addEventListener('focusout', () => {
|
||||
this.input.value = maskedToken
|
||||
this.input.value = this.maskedToken
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.id = `menu-active-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.querySelectorAll('a').forEach((link) => {
|
||||
if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) {
|
||||
link.classList.add('bg-base-300')
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { nativeIcon } from '../native'
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const bridge = window.webkit?.messageHandlers?.native
|
||||
|
||||
if (!bridge) return
|
||||
|
||||
const target = this.querySelector('a, button, [role="button"]') || this.firstElementChild
|
||||
|
||||
if (!target && !this.dataset.native) return
|
||||
|
||||
this.nativeId ||= `native-action-${Math.random().toString(32).split('.')[1]}`
|
||||
this.id = this.nativeId
|
||||
|
||||
if (target) target.dataset.nativeId = this.nativeId
|
||||
|
||||
bridge.postMessage({
|
||||
type: 'action',
|
||||
op: 'add',
|
||||
id: this.nativeId,
|
||||
title: this.dataset.label || target?.textContent.trim(),
|
||||
icon: nativeIcon(this.dataset.icon),
|
||||
placement: this.dataset.placement || 'menu',
|
||||
side: this.dataset.side,
|
||||
view: this.dataset.view,
|
||||
section: this.dataset.section,
|
||||
menu: this.dataset.menu,
|
||||
menuIcon: nativeIcon(this.dataset.menuIcon),
|
||||
native: this.dataset.native,
|
||||
url: this.dataset.url,
|
||||
accept: this.dataset.accept,
|
||||
destructive: this.dataset.destructive === 'true',
|
||||
selected: this.dataset.selected === 'true',
|
||||
haptic: this.dataset.haptic === 'true'
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.nativeId) {
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'action', op: 'remove', id: this.nativeId })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (this.dataset.on === 'submit') {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
this.addEventListener('turbo:before-fetch-response', this.onBeforeFetchResponse)
|
||||
} else {
|
||||
this.addEventListener('click', this.dispatch)
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
this.removeEventListener('turbo:before-fetch-response', this.onBeforeFetchResponse)
|
||||
this.removeEventListener('click', this.dispatch)
|
||||
}
|
||||
|
||||
onBeforeFetchResponse = (e) => {
|
||||
const response = e.detail.fetchResponse
|
||||
|
||||
if (window.webkit?.messageHandlers?.modal && response.succeeded && !response.redirected && !response.isHTML) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onSubmitEnd = async (e) => {
|
||||
const form = e.detail.formSubmission?.formElement
|
||||
|
||||
if (!e.detail.success || !form || !this.contains(form)) return
|
||||
|
||||
this.post(JSON.stringify({ form: form.id, action: form.action, body: await e.detail.fetchResponse.responseText }))
|
||||
}
|
||||
|
||||
dispatch = async () => {
|
||||
if (this.dataset.loading) return
|
||||
|
||||
let detail = this.dataset.detail || 'null'
|
||||
|
||||
if (this.dataset.src) {
|
||||
this.dataset.loading = 'true'
|
||||
|
||||
try {
|
||||
const response = await fetch(this.dataset.src, { headers: { Accept: 'application/json' } })
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
detail = await response.text()
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
delete this.dataset.loading
|
||||
}
|
||||
}
|
||||
|
||||
this.post(detail)
|
||||
}
|
||||
|
||||
post (detail) {
|
||||
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'dispatch', name: this.dataset.name, detail, dismiss: this.dataset.dismiss || 'true' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { nativeIcon } from '../native'
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (window.webkit?.messageHandlers?.native) {
|
||||
this.addEventListener('click', this.onClick)
|
||||
} else {
|
||||
document.addEventListener('pointerdown', this.onPointerDown)
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('click', this.onClick)
|
||||
document.removeEventListener('pointerdown', this.onPointerDown)
|
||||
}
|
||||
|
||||
onPointerDown = (event) => {
|
||||
if (this.contains(document.activeElement) && !this.contains(event.target)) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
}
|
||||
|
||||
onClick = (event) => {
|
||||
if (event.target.closest('native-menu-action')) return
|
||||
|
||||
const rect = this.firstElementChild.getBoundingClientRect()
|
||||
|
||||
const items = [...this.querySelectorAll('native-menu-action')].map((action) => {
|
||||
const target = action.querySelector('a, button, [role="button"]') || action.firstElementChild
|
||||
|
||||
target.dataset.nativeId ||= `native-menu-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
return {
|
||||
id: target.dataset.nativeId,
|
||||
title: action.dataset.label || target.textContent.trim(),
|
||||
icon: nativeIcon(action.dataset.icon),
|
||||
destructive: action.dataset.destructive === 'true',
|
||||
selected: action.dataset.selected === 'true',
|
||||
haptic: action.dataset.haptic === 'true'
|
||||
}
|
||||
})
|
||||
|
||||
window.webkit.messageHandlers.native.postMessage({
|
||||
type: 'menu',
|
||||
rect: { x: rect.left + window.scrollX, y: rect.top + window.scrollY, width: rect.width, height: rect.height },
|
||||
items
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||
this.addEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
||||
}
|
||||
|
||||
onSubmit = async (e) => {
|
||||
if (!e.detail.success) return
|
||||
if (e.detail.fetchResponse?.redirected && e.detail.fetchResponse.location.href !== window.location.href) return
|
||||
if (e.detail.formSubmission?.formElement?.method === 'get') return
|
||||
if (this.dataset.closeAfterSubmit === 'false') return
|
||||
if (e.detail.formSubmission?.formElement?.dataset?.closeOnSubmit === 'false') return
|
||||
if (e.detail.formSubmission?.formElement?.closest('native-event[data-on="submit"]')) return
|
||||
|
||||
const flash = await this.flashFrom(e.detail.fetchResponse)
|
||||
|
||||
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'submitted', ...flash })
|
||||
}
|
||||
|
||||
async flashFrom (fetchResponse) {
|
||||
const html = await fetchResponse?.responseHTML.catch(() => null)
|
||||
const flash = html && new DOMParser().parseFromString(html, 'text/html').getElementById('native_flash')
|
||||
|
||||
if (flash?.dataset.alert) return { alert: flash.dataset.alert }
|
||||
if (flash?.dataset.notice) return { notice: flash.dataset.notice }
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
onClick = () => {
|
||||
setTimeout(() => {
|
||||
if (document.documentElement.scrollHeight > window.innerHeight) {
|
||||
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'expand' })
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('native:search', this.onSearch)
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
this.addEventListener('touchstart', this.onTouchStart, { passive: true })
|
||||
this.addEventListener('scroll', this.onScroll, { capture: true, passive: true })
|
||||
|
||||
const scroller = this.querySelector('[data-scroll-top]')
|
||||
|
||||
if (scroller) scroller.scrollTop = scroller.dataset.scrollTop
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('native:search', this.onSearch)
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
if (this.scroller) this.scroller.dataset.scrollTop = this.scroller.scrollTop
|
||||
}
|
||||
|
||||
onTouchStart = () => {
|
||||
this.isTouching = true
|
||||
}
|
||||
|
||||
onScroll = (event) => {
|
||||
this.scroller = event.target
|
||||
|
||||
if (!this.isTouching) return
|
||||
|
||||
this.isTouching = false
|
||||
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'dismiss-keyboard' })
|
||||
}
|
||||
|
||||
onSearch = (event) => {
|
||||
const query = (event.detail?.q || '').trim()
|
||||
|
||||
if (query === (this.dataset.q || '')) return
|
||||
|
||||
this.dataset.q = query
|
||||
|
||||
const url = new URL(this.frame.src || this.dataset.src, window.location.href)
|
||||
|
||||
url.searchParams.delete('page')
|
||||
|
||||
if (query) {
|
||||
url.searchParams.set('q', query)
|
||||
} else {
|
||||
url.searchParams.delete('q')
|
||||
}
|
||||
|
||||
this.frame.src = url.toString()
|
||||
}
|
||||
|
||||
get frame () {
|
||||
return this.querySelector('turbo-frame')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { convertImagesInInput } from './convert_upload'
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.files = []
|
||||
|
||||
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
document.addEventListener('native:upload-start', this.onUploadStart)
|
||||
document.addEventListener('native:upload-chunk', this.onUploadChunk)
|
||||
document.addEventListener('native:upload-submit', this.onUploadSubmit)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
document.removeEventListener('native:upload-start', this.onUploadStart)
|
||||
document.removeEventListener('native:upload-chunk', this.onUploadChunk)
|
||||
document.removeEventListener('native:upload-submit', this.onUploadSubmit)
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.querySelector('form')
|
||||
}
|
||||
|
||||
onUploadStart = (event) => {
|
||||
this.files = []
|
||||
|
||||
event.detail.result = true
|
||||
}
|
||||
|
||||
onUploadChunk = ({ detail: { index, name, type, chunk } }) => {
|
||||
const file = this.files[index] ||= { name, type, chunks: [] }
|
||||
|
||||
file.chunks.push(Uint8Array.from(atob(chunk), (char) => char.charCodeAt(0)))
|
||||
}
|
||||
|
||||
onUploadSubmit = (event) => {
|
||||
event.detail.result = this.submit(event.detail.url)
|
||||
}
|
||||
|
||||
async submit (url) {
|
||||
const dataTransfer = new DataTransfer()
|
||||
|
||||
this.files.forEach(({ name, type, chunks }) => dataTransfer.items.add(new File(chunks, name, { type })))
|
||||
this.files = []
|
||||
|
||||
const input = this.form.querySelector('[type="file"]')
|
||||
|
||||
input.files = dataTransfer.files
|
||||
|
||||
await convertImagesInInput(input)
|
||||
|
||||
this.form.action = url
|
||||
this.form.requestSubmit()
|
||||
}
|
||||
|
||||
onSubmitEnd = (e) => {
|
||||
if (e.detail.formSubmission?.formElement !== this.form) return
|
||||
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'upload', success: e.detail.success })
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('pointerdown', this.onPointerdown)
|
||||
}
|
||||
|
||||
onPointerdown = (event) => {
|
||||
if (this.dataset.disabled === 'true') return
|
||||
if (/Hotwire Native/.test(navigator.userAgent)) return
|
||||
if (!this.isMobile()) return
|
||||
if (window.innerWidth >= 768) return
|
||||
|
||||
this.querySelectorAll('a[data-turbo-frame]').forEach((link) => {
|
||||
link.dataset.turboFrame = '_top'
|
||||
})
|
||||
const link = event.target.closest('a[data-turbo-frame]')
|
||||
|
||||
if (link) link.dataset.turboFrame = '_top'
|
||||
}
|
||||
|
||||
isMobile () {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.id = `scroll-fade-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.addEventListener('scroll', this.updateFade, { passive: true })
|
||||
window.addEventListener('resize', this.updateFade)
|
||||
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.button.addEventListener('click', (event) => {
|
||||
if (!this.input.value && document.activeElement !== this.input) {
|
||||
event.preventDefault()
|
||||
|
||||
this.input.focus()
|
||||
}
|
||||
})
|
||||
this.addEventListener('click', this.onClick)
|
||||
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
if (this.input?.value) {
|
||||
this.input.focus()
|
||||
this.input.setSelectionRange(this.input.value.length, this.input.value.length)
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
}
|
||||
|
||||
onClick = (event) => {
|
||||
if (!event.target.closest('button')) return
|
||||
|
||||
if (!this.input.value && document.activeElement !== this.input) {
|
||||
event.preventDefault()
|
||||
|
||||
this.input.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
this.input.value = this.input.getAttribute('value') || ''
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.id = `set-timezone-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
if (this.dataset.inputId) {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export default targetable(class extends HTMLElement {
|
||||
static [target.static] = ['canvas', 'input', 'clear', 'button']
|
||||
|
||||
async connectedCallback () {
|
||||
this.lockSwipe(true)
|
||||
|
||||
const { default: SignaturePad } = await import('signature_pad')
|
||||
|
||||
this.setCanvasSize()
|
||||
@@ -61,11 +63,17 @@ export default targetable(class extends HTMLElement {
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.lockSwipe(false)
|
||||
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
lockSwipe (locked) {
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'swipe', op: locked ? 'lock' : 'unlock' })
|
||||
}
|
||||
|
||||
setCanvasSize () {
|
||||
const scale = 3
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (this.lastElementChild) {
|
||||
this.init()
|
||||
} else {
|
||||
queueMicrotask(() => this.init())
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval)
|
||||
}
|
||||
}
|
||||
|
||||
init () {
|
||||
const form = this.querySelector('form') || (this.querySelector('input, button, select') || this.lastElementChild).form
|
||||
|
||||
if (this.dataset.interval) {
|
||||
@@ -24,10 +38,4 @@ export default class extends HTMLElement {
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
onClick = (event) => {
|
||||
const button = this.querySelector('a, button, label')
|
||||
|
||||
if (!button?.contains(event.target)) return
|
||||
|
||||
const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
this.dataset.classes.split(' ').forEach((cls) => {
|
||||
if (this.dataset.action === 'remove') {
|
||||
target.classList.remove(cls)
|
||||
} else if (this.dataset.action === 'add') {
|
||||
target.classList.add(cls)
|
||||
} else {
|
||||
target.classList.toggle(cls)
|
||||
}
|
||||
})
|
||||
this.dataset.classes.split(' ').forEach((cls) => {
|
||||
if (this.dataset.action === 'remove') {
|
||||
target.classList.remove(cls)
|
||||
} else if (this.dataset.action === 'add') {
|
||||
target.classList.add(cls)
|
||||
} else {
|
||||
target.classList.toggle(cls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.form.addEventListener('change', this.toggle)
|
||||
|
||||
this.toggle()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.form.removeEventListener('change', this.toggle)
|
||||
}
|
||||
|
||||
toggle = () => {
|
||||
const selector = this.dataset.names.split(' ').map((name) => `[name="${name}"]:checked:enabled`).join(', ')
|
||||
const isChecked = !!this.form.querySelector(selector)
|
||||
|
||||
this.firstElementChild.classList.toggle('hidden', !isChecked)
|
||||
this.lastElementChild.classList.toggle('hidden', isChecked)
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.closest('form')
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,21 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
|
||||
this.form.addEventListener('submit', this.onSubmit)
|
||||
this.form.addEventListener('turbo:before-fetch-response', this.onFetchResponse)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
|
||||
this.form.removeEventListener('submit', this.onSubmit)
|
||||
this.form.removeEventListener('turbo:before-fetch-response', this.onFetchResponse)
|
||||
}
|
||||
|
||||
onSubmit = () => {
|
||||
this.element.classList.add('invisible')
|
||||
}
|
||||
|
||||
onSubmitEnd = (event) => {
|
||||
if (event.target === this.form) {
|
||||
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
|
||||
|
||||
if (resp?.status / 100 === 2) {
|
||||
this.element.classList.remove('invisible')
|
||||
}
|
||||
onFetchResponse = (event) => {
|
||||
if (event.detail.fetchResponse.succeeded) {
|
||||
this.element.classList.remove('invisible')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.events.forEach((event) => this.form.addEventListener(event, this.toggle))
|
||||
|
||||
this.toggle()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.events.forEach((event) => this.form.removeEventListener(event, this.toggle))
|
||||
}
|
||||
|
||||
toggle = (event) => {
|
||||
if (event?.type === 'input' && event.target.matches(this.fieldSelector('name'))) return
|
||||
|
||||
if (event?.type === 'focusin' && !this.isKeepSendUsed && this.isFocusFromName(event)) {
|
||||
this.isKeepSendUsed = true
|
||||
this.keepSendInput = event.target
|
||||
} else if (event?.type === 'focusout' && event.target === this.keepSendInput) {
|
||||
this.keepSendInput = null
|
||||
}
|
||||
|
||||
const isNameOnly = (field) => this.isFilled('name') && !this.isFilled(field) && !this.keepSendInput?.matches(this.fieldSelector(field))
|
||||
|
||||
const isSend = (this.isChecked('send_email') && !isNameOnly('email')) ||
|
||||
(this.isChecked('send_sms') && !isNameOnly('phone'))
|
||||
|
||||
this.firstElementChild.classList.toggle('hidden', !isSend)
|
||||
this.lastElementChild.classList.toggle('hidden', isSend)
|
||||
}
|
||||
|
||||
isChecked (name) {
|
||||
return !!this.form.querySelector(`[name="${name}"]:checked:enabled`)
|
||||
}
|
||||
|
||||
isFilled (field) {
|
||||
return [...this.form.querySelectorAll(this.fieldSelector(field))].some((input) => input.value.trim())
|
||||
}
|
||||
|
||||
isFocusFromName ({ target, relatedTarget }) {
|
||||
return !!relatedTarget?.matches(this.fieldSelector('name')) && !!relatedTarget.value.trim() &&
|
||||
['email', 'phone'].some((field) => target.matches(this.fieldSelector(field)))
|
||||
}
|
||||
|
||||
fieldSelector (field) {
|
||||
return `[name$="[][${field}]"]`
|
||||
}
|
||||
|
||||
get events () {
|
||||
return ['change', 'input', 'linked-input.update', 'focusin', 'focusout']
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.closest('form')
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import './native'
|
||||
|
||||
import { createApp, reactive } from 'vue'
|
||||
|
||||
import Form from './submission_form/form'
|
||||
@@ -8,6 +10,7 @@ import ScrollButtons from './elements/scroll_buttons'
|
||||
import PageContainer from './elements/page_container'
|
||||
import SubmitForm from './elements/submit_form'
|
||||
import ModalButton from './elements/modal_button'
|
||||
import NativeAction from './elements/native_action'
|
||||
|
||||
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
|
||||
|
||||
@@ -18,6 +21,7 @@ safeRegisterElement('scroll-buttons', ScrollButtons)
|
||||
safeRegisterElement('page-container', PageContainer)
|
||||
safeRegisterElement('submit-form', SubmitForm)
|
||||
safeRegisterElement('modal-button', ModalButton)
|
||||
safeRegisterElement('native-action', NativeAction)
|
||||
safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.appElem = document.createElement('div')
|
||||
@@ -28,7 +32,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
optionalInviteSubmitters: JSON.parse(this.dataset.optionalInviteSubmitters),
|
||||
schema: JSON.parse(this.dataset.schema),
|
||||
canSendEmail: this.dataset.canSendEmail === 'true',
|
||||
previousSignatureValue: this.dataset.previousSignatureValue,
|
||||
prefillSignature: this.dataset.prefillSignature ? JSON.parse(this.dataset.prefillSignature) : null,
|
||||
prefillInitials: this.dataset.prefillInitials ? JSON.parse(this.dataset.prefillInitials) : null,
|
||||
currentUser: this.dataset.currentUser ? JSON.parse(this.dataset.currentUser) : null,
|
||||
goToLast: this.dataset.goToLast === 'true',
|
||||
isDemo: this.dataset.isDemo === 'true',
|
||||
attribution: this.dataset.attribution !== 'false',
|
||||
@@ -37,10 +43,12 @@ safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
language: this.dataset.language,
|
||||
paymentProvider: this.dataset.paymentProvider,
|
||||
dryRun: this.dataset.dryRun === 'true',
|
||||
viewToken: this.dataset.viewToken,
|
||||
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
|
||||
withSignatureId: this.dataset.withSignatureId === 'true',
|
||||
requireSigningReason: this.dataset.requireSigningReason === 'true',
|
||||
withConfetti: this.dataset.withConfetti === 'true',
|
||||
nativePlatform: this.dataset.nativePlatform,
|
||||
withFieldLabels: this.dataset.withFieldLabels !== 'false',
|
||||
withDisclosure: this.dataset.withDisclosure === 'true',
|
||||
reuseSignature: this.dataset.reuseSignature !== 'false',
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
const requestCache = new Map()
|
||||
const cacheTtl = 10 * 1000
|
||||
|
||||
function isPreloadable (linkElement) {
|
||||
const href = linkElement.getAttribute('href')
|
||||
|
||||
if (!href || href === '#' || linkElement.dataset.turbo === 'false' || linkElement.dataset.prefetch === 'false') {
|
||||
return
|
||||
}
|
||||
|
||||
if (linkElement.origin !== document.location.origin) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(linkElement.protocol)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (linkElement.pathname + linkElement.search === document.location.pathname + document.location.search) {
|
||||
return
|
||||
}
|
||||
|
||||
if (linkElement.dataset.turboMethod && linkElement.dataset.turboMethod !== 'get') {
|
||||
return
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function mouseoverListener (event) {
|
||||
let linkElement
|
||||
|
||||
if (event.target.tagName === 'A') {
|
||||
linkElement = event.target
|
||||
} else {
|
||||
linkElement = event.target.closest('a')
|
||||
}
|
||||
|
||||
if (!linkElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPreloadable(linkElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = linkElement.getAttribute('href')
|
||||
const loc = new URL(url, location.protocol + '//' + location.host)
|
||||
const absoluteUrl = loc.toString()
|
||||
|
||||
const cached = requestCache.get(absoluteUrl)
|
||||
|
||||
if (cached && cached.ttl > new Date()) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'text/html, application/xhtml+xml', 'VND.PREFETCH': 'true', 'X-Turbo': 'true' },
|
||||
method: 'GET',
|
||||
redirect: 'follow'
|
||||
}
|
||||
|
||||
if (linkElement.dataset.turboFrame && linkElement.dataset.turboFrame !== '_top') {
|
||||
requestOptions.headers['Turbo-Frame'] = linkElement.dataset.turboFrame
|
||||
} else if (linkElement.dataset.turboFrame !== '_top') {
|
||||
const turboFrame = linkElement.closest('turbo-frame')
|
||||
|
||||
if (turboFrame) {
|
||||
requestOptions.headers['Turbo-Frame'] = turboFrame.id
|
||||
}
|
||||
}
|
||||
|
||||
requestCache.set(absoluteUrl, { request: fetch(absoluteUrl, requestOptions), ttl: new Date(new Date().getTime() + cacheTtl) })
|
||||
}
|
||||
|
||||
function turboBeforeFetchRequest (event) {
|
||||
if (event.target.tagName !== 'FORM' && event.detail.fetchOptions.method === 'GET') {
|
||||
const cached = requestCache.get(event.detail.url.toString())
|
||||
|
||||
if (cached && cached.ttl > new Date()) {
|
||||
event.detail.response = cached.request
|
||||
}
|
||||
}
|
||||
|
||||
requestCache.clear()
|
||||
}
|
||||
|
||||
function start () {
|
||||
if (!window.turboInstantClickEnabled) {
|
||||
document.addEventListener('turbo:before-fetch-request', turboBeforeFetchRequest)
|
||||
document.addEventListener('mouseover', mouseoverListener, { capture: true, passive: true })
|
||||
}
|
||||
|
||||
window.turboInstantClickEnabled = true
|
||||
}
|
||||
|
||||
export { start }
|
||||
@@ -0,0 +1,211 @@
|
||||
const ICONS = {
|
||||
ios: {
|
||||
link: 'link',
|
||||
archive: 'archivebox',
|
||||
folder: 'folder',
|
||||
trash: 'trash',
|
||||
rotate: 'arrow.counterclockwise',
|
||||
copy: 'doc.on.doc',
|
||||
pencil: 'pencil',
|
||||
logs: 'list.bullet.rectangle',
|
||||
external_link: 'arrow.up.right.square',
|
||||
download: 'arrow.down.circle',
|
||||
mail_forward: 'paperplane',
|
||||
send: 'paperplane',
|
||||
writing: 'signature',
|
||||
adjustments_horizontal: 'slider.horizontal.3',
|
||||
file_text: 'doc.text',
|
||||
files: 'doc.on.doc',
|
||||
plus: 'plus',
|
||||
eye: 'eye',
|
||||
check: 'checkmark',
|
||||
code: 'chevron.left.forwardslash.chevron.right',
|
||||
history: 'clock.arrow.circlepath',
|
||||
user: 'person',
|
||||
users: 'person.2',
|
||||
users_plus: 'person.badge.plus',
|
||||
user_plus: 'person.badge.plus',
|
||||
eye_plus: 'eye',
|
||||
mail_plus: 'envelope',
|
||||
photo: 'photo',
|
||||
settings: 'gearshape',
|
||||
logout: 'rectangle.portrait.and.arrow.right',
|
||||
upload: 'arrow.up.doc',
|
||||
scan: 'doc.viewfinder',
|
||||
pencil_plus: 'square.and.pencil',
|
||||
arrow_sort: 'arrow.up.arrow.down',
|
||||
sort_descending_numbers: 'calendar',
|
||||
sort_descending_small_big: 'clock.arrow.circlepath',
|
||||
sort_ascending_letters: 'textformat.abc',
|
||||
calendar: 'calendar',
|
||||
calendar_check: 'calendar.badge.checkmark',
|
||||
info_circle: 'info.circle',
|
||||
list: 'list.bullet',
|
||||
clock: 'clock',
|
||||
circle_check: 'checkmark.circle'
|
||||
}
|
||||
}
|
||||
|
||||
const platform = navigator.userAgent.includes('Hotwire Native Android') ? 'android' : 'ios'
|
||||
|
||||
export function nativeIcon (name) {
|
||||
return ICONS[platform]?.[name] || name
|
||||
}
|
||||
|
||||
const handlers = window.webkit?.messageHandlers
|
||||
const isNativeLoaded = window.isNativeLoaded
|
||||
|
||||
if (handlers?.native && window === window.top && !isNativeLoaded) {
|
||||
window.isNativeLoaded = true
|
||||
|
||||
handlers.native.postMessage({ type: 'reset' })
|
||||
|
||||
const reportPathConfiguration = () => {
|
||||
const element = document.getElementById('native_path_configuration')
|
||||
|
||||
if (!element) return
|
||||
|
||||
const config = JSON.parse(element.textContent)
|
||||
|
||||
config.rules.forEach(({ properties }) => {
|
||||
if (properties.native_actions) properties.native_actions = properties.native_actions.map(nativeIcon)
|
||||
})
|
||||
|
||||
handlers.native.postMessage({ type: 'path-configuration', json: JSON.stringify(config) })
|
||||
}
|
||||
|
||||
document.addEventListener('turbo:load', reportPathConfiguration)
|
||||
|
||||
document.addEventListener('turbo:before-cache', () => {
|
||||
document.getElementById('native_path_configuration')?.remove()
|
||||
})
|
||||
|
||||
reportPathConfiguration()
|
||||
|
||||
const reportFlash = (element) => {
|
||||
if (!element) return
|
||||
|
||||
if (element.dataset.notice) handlers.flash.postMessage({ style: 'notice', message: element.dataset.notice })
|
||||
if (element.dataset.alert) handlers.flash.postMessage({ style: 'alert', message: element.dataset.alert })
|
||||
|
||||
element.remove()
|
||||
}
|
||||
|
||||
const reportPageFlash = () => reportFlash(document.getElementById('native_flash'))
|
||||
|
||||
new MutationObserver(reportPageFlash).observe(document.documentElement, { childList: true, subtree: true })
|
||||
|
||||
document.addEventListener('turbo:load', reportPageFlash)
|
||||
|
||||
document.addEventListener('native:flash', (event) => {
|
||||
reportFlash(new DOMParser().parseFromString(event.detail.html, 'text/html').getElementById('native_flash'))
|
||||
})
|
||||
|
||||
reportPageFlash()
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const link = event.target.closest('a[href]')
|
||||
|
||||
if (!link) return
|
||||
|
||||
const action = link.closest('native-action, native-menu-action')
|
||||
const title = link.dataset.nativeTitle || link.getAttribute('aria-label') || link.closest('[data-native-title]')?.dataset.nativeTitle || action?.dataset.label || action?.textContent.replace(/\s+/g, ' ').trim()
|
||||
|
||||
if (!title) return
|
||||
|
||||
const actions = link.closest('[data-native-actions]')?.dataset.nativeActions
|
||||
|
||||
handlers.native.postMessage({ type: 'title', url: link.href, title, actions: actions ? actions.split(',').map(nativeIcon) : null })
|
||||
}, true)
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (document.querySelector('native-modal')) return
|
||||
|
||||
const link = event.target.closest('a[data-turbo-frame="modal"], a[data-turbo-frame="drawer"]')
|
||||
|
||||
if (!link?.href) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
handlers.modal.postMessage({ action: 'open', url: link.href, modal: link.closest('[data-native-modal]')?.dataset.nativeModal || '' })
|
||||
}, true)
|
||||
|
||||
let isTouchPrefetch = false
|
||||
|
||||
document.addEventListener('touchstart', (event) => {
|
||||
isTouchPrefetch = true
|
||||
event.target.closest('a[href]')?.dispatchEvent(new MouseEvent('mouseenter'))
|
||||
isTouchPrefetch = false
|
||||
}, { passive: true })
|
||||
|
||||
document.addEventListener('turbo:before-prefetch', (event) => {
|
||||
if (!isTouchPrefetch || event.target.closest('a[data-turbo-frame="modal"], a[data-turbo-frame="drawer"]')) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('native:action', (event) => {
|
||||
document.querySelector(`[data-native-id="${event.detail.id}"]`)?.click()
|
||||
})
|
||||
|
||||
document.addEventListener('native:title-bottom', (event) => {
|
||||
const rect = document.querySelector('h1, h2')?.getBoundingClientRect()
|
||||
|
||||
event.detail.result = rect?.height ? rect.bottom - document.documentElement.getBoundingClientRect().top : null
|
||||
})
|
||||
}
|
||||
|
||||
if (handlers?.native && window.Turbo && !isNativeLoaded) {
|
||||
const staleSnapshots = new Set()
|
||||
let isRestoring = false
|
||||
|
||||
window.Turbo.session.view.clearSnapshotCache = function () {
|
||||
this.snapshotCache.keys.forEach((key) => staleSnapshots.add(key))
|
||||
}
|
||||
|
||||
window.Turbo.session.navigator.linkPrefetchingIsEnabledForLocation = () => !document.querySelector('native-modal')
|
||||
|
||||
document.addEventListener('turbo:submit-end', (e) => {
|
||||
if (e.detail.success && !e.detail.formSubmission.isSafe) {
|
||||
handlers.native.postMessage({ type: 'stale' })
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('native:pull-to-refresh', (event) => {
|
||||
const { session } = window.Turbo
|
||||
const url = new URL(document.baseURI)
|
||||
|
||||
url.searchParams.delete('limit')
|
||||
|
||||
session.history.replace(url, session.history.restorationIdentifier)
|
||||
session.view.lastRenderedLocation = url
|
||||
|
||||
event.detail.result = new Promise((resolve) => {
|
||||
document.addEventListener('turbo:render', () => resolve(true), { once: true })
|
||||
document.addEventListener('turbo:fetch-request-error', () => resolve(true), { once: true })
|
||||
|
||||
session.refresh(document.baseURI)
|
||||
})
|
||||
})
|
||||
|
||||
document.addEventListener('native:refresh', () => {
|
||||
window.Turbo.session.refresh(document.baseURI)
|
||||
})
|
||||
|
||||
document.addEventListener('native:turbo-stream', (e) => {
|
||||
if (!e.detail.body?.trimStart().startsWith('<turbo-stream')) return
|
||||
|
||||
window.Turbo.renderStreamMessage(e.detail.body)
|
||||
})
|
||||
|
||||
document.addEventListener('turbo:visit', (e) => {
|
||||
isRestoring = e.detail.action === 'restore'
|
||||
})
|
||||
|
||||
document.addEventListener('turbo:load', () => {
|
||||
if (staleSnapshots.delete(window.location.href.split('#')[0]) && isRestoring && window.location.pathname !== '/search') {
|
||||
setTimeout(() => window.Turbo.session.refresh(document.baseURI))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -125,6 +125,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
fieldsUuidIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -195,7 +200,7 @@ export default {
|
||||
},
|
||||
isMobileContainer: FieldAreas.computed.isMobileContainer,
|
||||
isInlineSize: FieldAreas.computed.isInlineSize,
|
||||
fieldsUuidIndex () {
|
||||
formulaFieldsUuidIndex () {
|
||||
return this.formulaFields.reduce((acc, field) => {
|
||||
acc[field.uuid] = field
|
||||
|
||||
@@ -289,6 +294,7 @@ export default {
|
||||
methods: {
|
||||
normalizeFormula: FormulaAreas.methods.normalizeFormula,
|
||||
calculateFormula: FormulaAreas.methods.calculateFormula,
|
||||
dateFormulaValue: FormulaAreas.methods.dateFormulaValue,
|
||||
evalTextFormula: FormulaAreas.methods.evalTextFormula,
|
||||
scrollInContainer: FieldAreas.methods.scrollInContainer,
|
||||
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
|
||||
|
||||
@@ -633,6 +633,32 @@ defineFunction('contains', {
|
||||
return String(v[0]).indexOf(String(v[1])) !== -1
|
||||
}
|
||||
})
|
||||
defineFunction('today', {
|
||||
min: 0,
|
||||
max: 0,
|
||||
call: () => {
|
||||
const now = new Date()
|
||||
return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86400000
|
||||
}
|
||||
})
|
||||
defineFunction('datedif', {
|
||||
min: 3,
|
||||
max: 3,
|
||||
call: (v) => {
|
||||
if (v[0] == null || v[1] == null) return null
|
||||
const start = toNumber(v[0])
|
||||
const end = toNumber(v[1])
|
||||
const unit = String(v[2]).toLowerCase()
|
||||
if (unit === 'd') return end - start
|
||||
const from = new Date(Math.min(start, end) * 86400000)
|
||||
const to = new Date(Math.max(start, end) * 86400000)
|
||||
const months = (to.getUTCFullYear() - from.getUTCFullYear()) * 12 + to.getUTCMonth() - from.getUTCMonth() - (to.getUTCDate() < from.getUTCDate() ? 1 : 0)
|
||||
const sign = end < start ? -1 : 1
|
||||
if (unit === 'm') return sign * months
|
||||
if (unit === 'y') return sign * Math.floor(months / 12)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const OP_CLASSES = {
|
||||
add: Addition,
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!isWebView && withDownloadButton"
|
||||
v-if="(!isWebView || isNativeApp) && withDownloadButton"
|
||||
class="base-button flex items-center space-x-1 w-full completed-form-download-button"
|
||||
:disabled="isDownloading"
|
||||
@click.prevent="download"
|
||||
@@ -186,6 +186,11 @@ export default {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
nativePlatform: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@@ -195,6 +200,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isNativeApp () {
|
||||
return !!this.nativePlatform && !!window.webkit?.messageHandlers?.download
|
||||
},
|
||||
isWebView () {
|
||||
return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent)
|
||||
}
|
||||
@@ -230,6 +238,12 @@ export default {
|
||||
download () {
|
||||
this.isDownloading = true
|
||||
|
||||
if (this.isNativeApp) {
|
||||
document.addEventListener('native:download-end', () => { this.isDownloading = false }, { once: true })
|
||||
|
||||
return window.webkit.messageHandlers.download.postMessage({ src: this.baseUrl + `/s/${this.submitterSlug}/documents` })
|
||||
}
|
||||
|
||||
fetch(this.baseUrl + `/s/${this.submitterSlug}/documents`, {
|
||||
method: 'GET',
|
||||
...this.fetchOptions
|
||||
|
||||
@@ -87,7 +87,7 @@ export default {
|
||||
IconCloudUpload,
|
||||
IconInnerShadowTop
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
inject: ['baseUrl', 'fetchOptions', 't'],
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
@@ -197,9 +197,20 @@ export default {
|
||||
formData.append('submitter_slug', this.submitterSlug)
|
||||
formData.append('name', 'attachments')
|
||||
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
}).then((resp) => {
|
||||
if (resp.status === 404) {
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
})
|
||||
} else {
|
||||
return resp
|
||||
}
|
||||
}).then(async (resp) => {
|
||||
const data = await resp.json()
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
:readonly-conditional-fields="readonlyConditionalFields"
|
||||
:readonly-conditional-field-values="readonlyConditionalFieldValues"
|
||||
:formula-fields="formulaFields"
|
||||
:fields-uuid-index="fieldsUuidIndex"
|
||||
:values="values"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:submitter="submitter"
|
||||
@@ -71,6 +72,7 @@
|
||||
<FormulaFieldAreas
|
||||
v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
|
||||
:fields="formulaFields"
|
||||
:fields-uuid-index="fieldsUuidIndex"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:values="values"
|
||||
/>
|
||||
@@ -489,8 +491,8 @@
|
||||
:reason="values[currentField.preferences?.reason_field_uuid]"
|
||||
:field="currentField"
|
||||
:values="values"
|
||||
:previous-value="previousSignatureValueFor(currentField) || previousSignatureValue"
|
||||
:touch-attachment-uuid="previousSignatureValue"
|
||||
:previous-value="previousSignatureValueFor(currentField)"
|
||||
:prefill-data="prefillSignature"
|
||||
:with-typed-signature="withTypedSignature"
|
||||
:remember-signature="rememberSignature"
|
||||
:attachments-index="attachmentsIndex"
|
||||
@@ -503,8 +505,8 @@
|
||||
:with-qr-button="withQrButton"
|
||||
:submitter="submitter"
|
||||
:show-field-names="showFieldNames"
|
||||
:native-platform="nativePlatform"
|
||||
@update:reason="values[currentField.preferences?.reason_field_uuid] = $event"
|
||||
@touch-attachment="attachmentsIndex[previousSignatureValue] ? attachmentsIndex[previousSignatureValue].created_at = new Date() : null"
|
||||
@attached="attachments.push($event)"
|
||||
@start="scrollIntoField(currentField)"
|
||||
@minimize="minimizeForm"
|
||||
@@ -518,9 +520,11 @@
|
||||
:dry-run="dryRun"
|
||||
:submitter="submitter"
|
||||
:previous-value="previousInitialsValue"
|
||||
:prefill-data="prefillInitials"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:show-field-names="showFieldNames"
|
||||
:submitter-slug="submitterSlug"
|
||||
:native-platform="nativePlatform"
|
||||
@attached="attachments.push($event)"
|
||||
@start="scrollIntoField(currentField)"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
@@ -558,6 +562,7 @@
|
||||
:field="currentField"
|
||||
:submitter-slug="submitterSlug"
|
||||
:fields="formulaFields"
|
||||
:fields-uuid-index="fieldsUuidIndex"
|
||||
:values="values"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:fetch-options="fetchOptions"
|
||||
@@ -652,6 +657,7 @@
|
||||
:with-confetti="withConfetti"
|
||||
:can-send-email="canSendEmail && !!submitter.email"
|
||||
:submitter-slug="submitterSlug"
|
||||
:native-platform="nativePlatform"
|
||||
/>
|
||||
<nav
|
||||
v-if="stepFields.length < 80"
|
||||
@@ -772,6 +778,7 @@ export default {
|
||||
provide () {
|
||||
return {
|
||||
baseUrl: this.baseUrl,
|
||||
fetchOptions: this.fetchOptions,
|
||||
scrollIntoArea: this.scrollIntoArea,
|
||||
scrollIntoField: this.scrollIntoField,
|
||||
t: this.t
|
||||
@@ -909,6 +916,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
nativePlatform: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
autoscrollFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -969,10 +981,20 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
previousSignatureValue: {
|
||||
type: String,
|
||||
prefillSignature: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: ''
|
||||
default: null
|
||||
},
|
||||
prefillInitials: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
currentUser: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
allowToSkip: {
|
||||
type: Boolean,
|
||||
@@ -994,6 +1016,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
viewToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
attribution: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -1329,6 +1356,30 @@ export default {
|
||||
|
||||
screen?.orientation?.addEventListener('change', this.onOrientationChange)
|
||||
|
||||
if (this.currentUser) {
|
||||
this.fields.forEach((field) => {
|
||||
if (field.readonly || field.type !== 'text') {
|
||||
return
|
||||
}
|
||||
|
||||
const fieldName = (field.name || '').toLowerCase()
|
||||
|
||||
let value
|
||||
|
||||
if (fieldName === 'full name' || fieldName === 'legal name') {
|
||||
value = this.currentUser.full_name
|
||||
} else if (fieldName === 'first name') {
|
||||
value = this.currentUser.first_name
|
||||
} else if (fieldName === 'last name') {
|
||||
value = this.currentUser.last_name
|
||||
}
|
||||
|
||||
if (value) {
|
||||
this.values[field.uuid] ??= value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.fields.forEach((field) => {
|
||||
if (field.default_value && !field.readonly) {
|
||||
this.values[field.uuid] ||= field.default_value
|
||||
@@ -1523,15 +1574,10 @@ export default {
|
||||
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
|
||||
window.history.replaceState({}, document.title, newUrl)
|
||||
|
||||
return fetch(this.baseUrl + '/api/submitter_email_clicks', {
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/click_email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
t,
|
||||
submitter_slug: this.submitterSlug
|
||||
})
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers },
|
||||
body: JSON.stringify({ t })
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve({})
|
||||
@@ -1547,29 +1593,28 @@ export default {
|
||||
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
|
||||
window.history.replaceState({}, document.title, newUrl)
|
||||
|
||||
return fetch(this.baseUrl + '/api/submitter_sms_clicks', {
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/click_sms`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
c,
|
||||
submitter_slug: this.submitterSlug
|
||||
})
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers },
|
||||
body: JSON.stringify({ c })
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
},
|
||||
trackViewForm () {
|
||||
fetch(this.baseUrl + '/api/submitter_form_views', {
|
||||
fetch(this.baseUrl + `/s/${this.submitterSlug}/view`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug
|
||||
})
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ v: this.viewToken })
|
||||
}).then((resp) => {
|
||||
if (resp.status === 404) {
|
||||
return fetch(this.baseUrl + '/api/submitter_form_views', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ submitter_slug: this.submitterSlug })
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
previousSignatureValueFor (field) {
|
||||
@@ -1767,6 +1812,10 @@ export default {
|
||||
this.isCompleted = true
|
||||
this.isFormVisible = true
|
||||
|
||||
if (this.nativePlatform) {
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'stale' })
|
||||
}
|
||||
|
||||
if (resp?.text) {
|
||||
const respData = await resp.text()
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@ export default {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
fieldsUuidIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@@ -59,7 +64,7 @@ export default {
|
||||
isInlineSize () {
|
||||
return CSS.supports('container-type: size')
|
||||
},
|
||||
fieldsUuidIndex () {
|
||||
formulaFieldsUuidIndex () {
|
||||
return this.fields.reduce((acc, field) => {
|
||||
acc[field.uuid] = field
|
||||
|
||||
@@ -82,8 +87,8 @@ export default {
|
||||
if (depth > 10) return formula
|
||||
|
||||
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
if (this.fieldsUuidIndex[uuid]) {
|
||||
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
|
||||
if (this.formulaFieldsUuidIndex[uuid]) {
|
||||
return `(${this.normalizeFormula(this.formulaFieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
|
||||
} else {
|
||||
return match
|
||||
}
|
||||
@@ -91,16 +96,36 @@ export default {
|
||||
},
|
||||
calculateFormula (field) {
|
||||
const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
|
||||
const value = this.readonlyValues[uuid] || this.values[uuid]
|
||||
|
||||
if (this.fieldsUuidIndex[uuid]?.type === 'date') {
|
||||
return this.dateFormulaValue(value)
|
||||
}
|
||||
|
||||
return value || 0.0
|
||||
})
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
const value = this.math.evaluate(transformedFormula.toLowerCase())
|
||||
|
||||
if (field.type === 'date') {
|
||||
return Number.isFinite(value) ? new Date(Math.floor(value) * 86400000).toISOString().slice(0, 10) : ''
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
dateFormulaValue (value) {
|
||||
if (!value) return 'null'
|
||||
if (value === '{{date}}') return 'today()'
|
||||
|
||||
const [year, month, day = 1] = value.slice(0, 10).split('-').map(Number)
|
||||
|
||||
return Date.UTC(year, month - 1, day) / 86400000
|
||||
},
|
||||
evalTextFormula (field, depth = 0) {
|
||||
if (depth > 10) return ''
|
||||
|
||||
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
const formulaField = this.fieldsUuidIndex[uuid]
|
||||
const formulaField = this.formulaFieldsUuidIndex[uuid]
|
||||
|
||||
if (formulaField?.preferences?.formula) {
|
||||
if (formulaField.type === 'text') {
|
||||
|
||||
@@ -13,6 +13,7 @@ const en = {
|
||||
must_be_valid_email: 'Must be a valid email address',
|
||||
must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)',
|
||||
must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Must be a valid IBAN',
|
||||
must_contain_numbers_only: 'Must contain numbers only',
|
||||
must_contain_letters_only: 'Must contain letters only',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
|
||||
@@ -132,6 +133,7 @@ const es = {
|
||||
must_be_valid_email: 'Debe ser una dirección de correo electrónico válida',
|
||||
must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)',
|
||||
must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Debe ser un IBAN válido',
|
||||
must_contain_numbers_only: 'Debe contener solo números',
|
||||
must_contain_letters_only: 'Debe contener solo letras',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
|
||||
@@ -251,6 +253,7 @@ const it = {
|
||||
must_be_valid_email: 'Deve essere un indirizzo email valido',
|
||||
must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)',
|
||||
must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Deve essere un IBAN valido',
|
||||
must_contain_numbers_only: 'Deve contenere solo numeri',
|
||||
must_contain_letters_only: 'Deve contenere solo lettere',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
|
||||
@@ -370,6 +373,7 @@ const de = {
|
||||
must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein',
|
||||
must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)',
|
||||
must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Muss eine gültige IBAN sein',
|
||||
must_contain_numbers_only: 'Darf nur Zahlen enthalten',
|
||||
must_contain_letters_only: 'Darf nur Buchstaben enthalten',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
|
||||
@@ -489,6 +493,7 @@ const fr = {
|
||||
must_be_valid_email: 'Doit être une adresse e-mail valide',
|
||||
must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)',
|
||||
must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Doit être un IBAN valide',
|
||||
must_contain_numbers_only: 'Doit contenir uniquement des chiffres',
|
||||
must_contain_letters_only: 'Doit contenir uniquement des lettres',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
|
||||
@@ -608,6 +613,7 @@ const pl = {
|
||||
must_be_valid_email: 'Musi być prawidłowym adresem e-mail',
|
||||
must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)',
|
||||
must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Musi być prawidłowym numerem IBAN',
|
||||
must_contain_numbers_only: 'Może zawierać tylko cyfry',
|
||||
must_contain_letters_only: 'Może zawierać tylko litery',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
|
||||
@@ -727,6 +733,7 @@ const uk = {
|
||||
must_be_valid_email: 'Має бути дійсною адресою електронної пошти',
|
||||
must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)',
|
||||
must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Має бути дійсним IBAN',
|
||||
must_contain_numbers_only: 'Має містити лише цифри',
|
||||
must_contain_letters_only: 'Має містити лише літери',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
|
||||
@@ -846,6 +853,7 @@ const cs = {
|
||||
must_be_valid_email: 'Musí být platná e-mailová adresa',
|
||||
must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)',
|
||||
must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Musí být platný IBAN',
|
||||
must_contain_numbers_only: 'Musí obsahovat pouze čísla',
|
||||
must_contain_letters_only: 'Musí obsahovat pouze písmena',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
|
||||
@@ -965,6 +973,7 @@ const pt = {
|
||||
must_be_valid_email: 'Deve ser um endereço de e-mail válido',
|
||||
must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)',
|
||||
must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Deve ser um IBAN válido',
|
||||
must_contain_numbers_only: 'Deve conter apenas números',
|
||||
must_contain_letters_only: 'Deve conter apenas letras',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
|
||||
@@ -1084,6 +1093,7 @@ const he = {
|
||||
must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית',
|
||||
must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)',
|
||||
must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'חייב להיות IBAN חוקי',
|
||||
must_contain_numbers_only: 'חייב להכיל מספרים בלבד',
|
||||
must_contain_letters_only: 'חייב להכיל אותיות בלבד',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
|
||||
@@ -1203,6 +1213,7 @@ const nl = {
|
||||
must_be_valid_email: 'Moet een geldig e-mailadres zijn',
|
||||
must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)',
|
||||
must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Moet een geldig IBAN zijn',
|
||||
must_contain_numbers_only: 'Mag alleen cijfers bevatten',
|
||||
must_contain_letters_only: 'Mag alleen letters bevatten',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
|
||||
@@ -1322,6 +1333,7 @@ const ar = {
|
||||
must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا',
|
||||
must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)',
|
||||
must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'يجب أن يكون IBAN صالحًا',
|
||||
must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط',
|
||||
must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
|
||||
@@ -1441,6 +1453,7 @@ const ko = {
|
||||
must_be_valid_email: '유효한 이메일 주소여야 합니다',
|
||||
must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)',
|
||||
must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)',
|
||||
must_be_valid_iban: '유효한 IBAN이어야 합니다',
|
||||
must_contain_numbers_only: '숫자만 포함해야 합니다',
|
||||
must_contain_letters_only: '문자만 포함해야 합니다',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
|
||||
@@ -1560,6 +1573,7 @@ const ja = {
|
||||
must_be_valid_email: '有効なメールアドレスである必要があります',
|
||||
must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)',
|
||||
must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)',
|
||||
must_be_valid_iban: '有効なIBANである必要があります',
|
||||
must_contain_numbers_only: '数字のみを含む必要があります',
|
||||
must_contain_letters_only: '文字のみを含む必要があります',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
v-if="modelValue || computedPreviousValue || computedPrefillData"
|
||||
type="button"
|
||||
class="btn font-medium btn-outline btn-sm clear-canvas-button"
|
||||
@click="remove"
|
||||
@@ -139,8 +139,8 @@
|
||||
:name="`values[${field.uuid}]`"
|
||||
>
|
||||
<img
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
|
||||
v-if="modelValue || computedPreviousValue || computedPrefillData"
|
||||
:src="previousValueUrl"
|
||||
:alt="field.name || t('initials')"
|
||||
class="mx-auto bg-white border border-base-300 rounded max-h-44"
|
||||
>
|
||||
@@ -150,20 +150,23 @@
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<label
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue && !computedPrefillData"
|
||||
for="initials_text_input"
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
v-show="!modelValue && !computedPreviousValue && !computedPrefillData"
|
||||
ref="canvas"
|
||||
role="img"
|
||||
:aria-label="t('initials_drawing_area')"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
||||
@touchstart.passive="lockSwipe"
|
||||
@touchend.passive="unlockSwipe"
|
||||
@touchcancel.passive="unlockSwipe"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue && !computedPrefillData"
|
||||
id="initials_text_input"
|
||||
ref="textInput"
|
||||
class="base-input !text-2xl w-full mt-6 text-center"
|
||||
@@ -199,7 +202,7 @@ export default {
|
||||
MarkdownContent,
|
||||
IconArrowsDiagonalMinimize2
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
inject: ['baseUrl', 'fetchOptions', 't'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -233,6 +236,16 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
prefillData: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
nativePlatform: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -255,10 +268,24 @@ export default {
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
computedPrefillData () {
|
||||
if (this.isUsePreviousValue && this.field.required === true && !this.previousValue && !this.modelValue) {
|
||||
return this.prefillData
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
previousValueUrl () {
|
||||
if (this.computedPrefillData) {
|
||||
return this.computedPrefillData.url
|
||||
} else {
|
||||
return this.attachmentsIndex[this.modelValue || this.computedPreviousValue].url
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.isInitialsStarted = !!this.computedPreviousValue
|
||||
this.isInitialsStarted = !!(this.computedPreviousValue || this.computedPrefillData)
|
||||
},
|
||||
async mounted () {
|
||||
this.$nextTick(() => {
|
||||
@@ -311,6 +338,8 @@ export default {
|
||||
this.intersectionObserver?.disconnect()
|
||||
},
|
||||
methods: {
|
||||
lockSwipe: SignatureStep.methods.lockSwipe,
|
||||
unlockSwipe: SignatureStep.methods.unlockSwipe,
|
||||
drawOnCanvas: SignatureStep.methods.drawOnCanvas,
|
||||
drawImage (event) {
|
||||
this.remove()
|
||||
@@ -397,7 +426,48 @@ export default {
|
||||
this.updateWrittenInitials({ target: this.$refs.textInput })
|
||||
}
|
||||
},
|
||||
submitPrefillAttachment () {
|
||||
if (this.dryRun) {
|
||||
const attachment = { uuid: Math.random().toString(), url: this.prefillData.url }
|
||||
|
||||
this.$emit('attached', attachment)
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
|
||||
return Promise.resolve(attachment)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('prefill_token', this.prefillData.token)
|
||||
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/prefill_attachments`, {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
}).then(async (resp) => {
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json()
|
||||
|
||||
return Promise.reject(new Error(data.error))
|
||||
}
|
||||
|
||||
const attachment = await resp.json()
|
||||
|
||||
this.$emit('attached', attachment)
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
|
||||
return attachment
|
||||
}).catch((error) => {
|
||||
this.remove()
|
||||
|
||||
return Promise.reject(error)
|
||||
})
|
||||
},
|
||||
async submit () {
|
||||
if (this.computedPrefillData) {
|
||||
return this.submitPrefillAttachment()
|
||||
}
|
||||
|
||||
if (this.modelValue || this.computedPreviousValue) {
|
||||
if (this.computedPreviousValue) {
|
||||
this.$emit('update:model-value', this.computedPreviousValue)
|
||||
@@ -431,9 +501,20 @@ export default {
|
||||
formData.append('name', 'attachments')
|
||||
formData.append('type', 'initials')
|
||||
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
}).then((resp) => {
|
||||
if (resp.status === 404) {
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
})
|
||||
} else {
|
||||
return resp
|
||||
}
|
||||
}).then(async (resp) => {
|
||||
if (resp.status === 422 || resp.status === 500) {
|
||||
const data = await resp.json()
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
v-if="!error"
|
||||
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
|
||||
<span class="flex items-end">
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
|
||||
<span
|
||||
class="tooltip tooltip-bottom ml-2 mb-1"
|
||||
data-tip="To verify your identity, the details you enter are checked against third-party records, which may include credit bureau data, and used to generate questions based on those records. Only these identity details are shared with the identity verification provider; this document and your tax return information are not shared with it or with any credit reporting company. If the check is recorded on your credit report, it appears as a soft inquiry, which does not affect your credit score and is not shown to lenders. This check does not give the sender of this document or the IRS access to your credit report."
|
||||
>
|
||||
<IconInfoCircle class="w-4 h-4 text-neutral-500" />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="questions"
|
||||
class="float-right font-normal text-neutral-500 mt-1 whitespace-nowrap text-right text-sm"
|
||||
@@ -282,13 +290,14 @@
|
||||
|
||||
<script>
|
||||
import MarkdownContent from './markdown_content'
|
||||
import { IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
import { IconInnerShadowTop, IconInfoCircle } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'KbaStep',
|
||||
components: {
|
||||
MarkdownContent,
|
||||
IconInnerShadowTop
|
||||
IconInnerShadowTop,
|
||||
IconInfoCircle
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
props: {
|
||||
@@ -483,7 +492,7 @@ export default {
|
||||
this.clearCountdown()
|
||||
|
||||
try {
|
||||
const payload = { ...this.form, submitter_slug: this.submitterSlug }
|
||||
const payload = { ...this.form }
|
||||
|
||||
if (payload.dob) {
|
||||
payload.dob = payload.dob.replace(/-/g, '')
|
||||
@@ -497,7 +506,7 @@ export default {
|
||||
payload.phone = payload.phone.replace(/^\+1/, '')
|
||||
}
|
||||
|
||||
const resp = await fetch(this.baseUrl + '/api/kba', {
|
||||
const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
...this.fetchOptions,
|
||||
@@ -548,13 +557,12 @@ export default {
|
||||
}, {})
|
||||
|
||||
try {
|
||||
const resp = await fetch(this.baseUrl + `/api/kba/${this.field.uuid}`, {
|
||||
const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba/${this.field.uuid}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
token: this.token,
|
||||
answers: formattedAnswers,
|
||||
reference: this.reference,
|
||||
submitter_slug: this.submitterSlug
|
||||
reference: this.reference
|
||||
}),
|
||||
...this.fetchOptions,
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||
|
||||
@@ -104,6 +104,11 @@ export default {
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
fieldsUuidIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: true
|
||||
@@ -137,7 +142,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldsUuidIndex () {
|
||||
formulaFieldsUuidIndex () {
|
||||
return this.fields.reduce((acc, field) => {
|
||||
acc[field.uuid] = field
|
||||
|
||||
@@ -151,7 +156,7 @@ export default {
|
||||
return this.provider === 'paypal'
|
||||
},
|
||||
paymentEndpoint () {
|
||||
return this.isPaypal ? '/api/paypal_payments' : '/api/stripe_payments'
|
||||
return `/s/${this.submitterSlug}/${this.isPaypal ? 'paypal_payments' : 'stripe_payments'}`
|
||||
},
|
||||
sessionId () {
|
||||
return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token'))
|
||||
@@ -216,17 +221,31 @@ export default {
|
||||
},
|
||||
calculateFormula () {
|
||||
const transformedFormula = this.normalizeFormula(this.field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
|
||||
const value = this.readonlyValues[uuid] || this.values[uuid]
|
||||
|
||||
if (this.fieldsUuidIndex[uuid]?.type === 'date') {
|
||||
return this.dateFormulaValue(value)
|
||||
}
|
||||
|
||||
return value || 0.0
|
||||
})
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
},
|
||||
dateFormulaValue (value) {
|
||||
if (!value) return 'null'
|
||||
if (value === '{{date}}') return 'today()'
|
||||
|
||||
const [year, month, day = 1] = value.slice(0, 10).split('-').map(Number)
|
||||
|
||||
return Date.UTC(year, month - 1, day) / 86400000
|
||||
},
|
||||
normalizeFormula (formula, depth = 0) {
|
||||
if (depth > 10) return formula
|
||||
|
||||
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
if (this.fieldsUuidIndex[uuid]) {
|
||||
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
|
||||
if (this.formulaFieldsUuidIndex[uuid]) {
|
||||
return `(${this.normalizeFormula(this.formulaFieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
|
||||
} else {
|
||||
return match
|
||||
}
|
||||
@@ -236,9 +255,6 @@ export default {
|
||||
if (this.sessionId) {
|
||||
return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug
|
||||
}),
|
||||
...this.fetchOptions,
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||
}).then(async (resp) => {
|
||||
@@ -275,7 +291,6 @@ export default {
|
||||
fetch(this.baseUrl + this.paymentEndpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
field_uuid: this.field.uuid,
|
||||
check_status: checkStatus,
|
||||
referer: this.normalizedPaymentUrl()
|
||||
|
||||
@@ -285,7 +285,7 @@ export default {
|
||||
}, 1000)
|
||||
},
|
||||
sendVerificationCode () {
|
||||
return fetch(this.baseUrl + '/api/send_phone_verification_code', {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
@@ -293,6 +293,14 @@ export default {
|
||||
phone: this.fullInternationalPhoneValue
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/send_phone_verification_code`, options).then((resp) => {
|
||||
if (resp.status === 404) {
|
||||
return fetch(this.baseUrl + '/api/send_phone_verification_code', options)
|
||||
} else {
|
||||
return resp
|
||||
}
|
||||
}).then(async (resp) => {
|
||||
if ([422, 429].includes(resp.status)) {
|
||||
const data = await resp.json()
|
||||
|
||||
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user