mirror of
https://github.com/docusealco/docuseal.git
synced 2026-08-07 07:14:43 +00:00
Compare commits
163 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9277f4eeb | |||
| 4052b6fe13 | |||
| 608347b1e6 | |||
| 345fe674d4 | |||
| 92f8901a67 | |||
| fcd40308ee | |||
| 2bd023e5d0 | |||
| a697d6330e | |||
| ab56e8be20 | |||
| b9a906b569 | |||
| 02adbae758 | |||
| 866aab7fac | |||
| c81b4d855b | |||
| 4ad58fc285 | |||
| 8227dd4f6a | |||
| beb677734f | |||
| 03db636d51 | |||
| b83abc9499 | |||
| 9738cdcf8f | |||
| 179ae01551 | |||
| 295860a9de | |||
| 0a6c15b6b8 | |||
| 18f80020ee | |||
| 3768fb94bf | |||
| 7b2cadcb91 | |||
| 4eb4e8c1f8 | |||
| 984df15949 | |||
| aa3bd24332 | |||
| 250889623d | |||
| 862fa55986 | |||
| 0f2889904a | |||
| 49f14c7929 | |||
| c3f3c5d5df | |||
| 274259f305 | |||
| a15d7a9cf3 | |||
| 7d154fc28d | |||
| 6727d3be83 | |||
| 4bbd5c3bcb | |||
| 5995f2e427 | |||
| 0910c8e100 | |||
| 8df76dfa90 | |||
| 78e9e66cb0 | |||
| 71c61fc4b3 | |||
| a728aa8689 | |||
| 5a3174a720 | |||
| 652072d660 | |||
| 323f5c0c15 | |||
| 229d78c78b | |||
| 9d9a955149 | |||
| b40a047190 | |||
| ff9e415823 | |||
| 2f7a4fb2b3 | |||
| f8a654a5b6 | |||
| 4449475467 | |||
| bcbbf0dd42 | |||
| 19b9ba52d4 | |||
| bdc30db2dc | |||
| 74634ba96b | |||
| 77feb05bad | |||
| a1f05d006e | |||
| cc3dae1335 | |||
| d9debcfb0d | |||
| 8770d16d52 | |||
| 84953481e4 | |||
| df7aa59f6f | |||
| 008b83119d | |||
| 15dafcb6ff | |||
| 7d291ed888 | |||
| a7221f0750 | |||
| 9a5b2a5d30 | |||
| 7361a00d42 | |||
| e07d402782 | |||
| a34354bee1 | |||
| 84b762b529 | |||
| 2a839951b4 | |||
| 26d7c2f047 | |||
| 151a5b85f3 | |||
| 3f506f53b4 | |||
| 95f734e4b8 | |||
| ff74574e84 | |||
| 843f6c405f | |||
| f8b24ebfce | |||
| d1d26b5424 | |||
| 8aa6f45da8 | |||
| 48d5bee95b | |||
| 98c79d2dec | |||
| 0f51238f62 | |||
| b7323b929f | |||
| 05eb1537f7 | |||
| bc9f27cfed | |||
| 8f457be719 | |||
| 6d590e13c9 | |||
| c987ec172a | |||
| 49f1637c47 | |||
| 13379bd699 | |||
| 5ee762c08c | |||
| edb6d561f1 | |||
| 1b90f77e82 | |||
| c37785ecbc | |||
| c67188edf8 | |||
| 8630c68631 | |||
| ddcb5c5d40 | |||
| 07f0fbb50f | |||
| 3781b0aa72 | |||
| 42a5cea521 | |||
| fb6000ca2c | |||
| 59f98db754 | |||
| 6add842a27 | |||
| 43ca4a6365 | |||
| 68196d6af0 | |||
| a582d39bb9 | |||
| 7a3e89c4d8 | |||
| 87f036ad88 | |||
| 5738b8dd58 | |||
| 8d73371e0f | |||
| 3d870b2305 | |||
| 27d8cd57bd | |||
| 353b2e7a94 | |||
| d77bd87acb | |||
| bae7b924ae | |||
| 89867fdde3 | |||
| c9a1f067ac | |||
| eb26835d57 | |||
| 7301661468 | |||
| e4fe984a70 | |||
| 12ccf622c1 | |||
| 6c35ff6d1c | |||
| 1d7c84302e | |||
| f3e8889710 | |||
| 4b2f9ff306 | |||
| b4ba642f8e | |||
| 5c69b965c0 | |||
| cba196385c | |||
| 00abad56cb | |||
| d1070c4955 | |||
| e84be523c6 | |||
| 5d598e8268 | |||
| 0e9d922204 | |||
| 80c1b04e83 | |||
| b2fca3edf3 | |||
| 2a292c8e80 | |||
| e6052ad0d8 | |||
| 8646e17d5a | |||
| e6785b0afd | |||
| 009033a411 | |||
| fdc3d7742a | |||
| f9ccca1376 | |||
| 0a00fe6ab8 | |||
| 6253e80a70 | |||
| 3dcb7c813f | |||
| 39cdc41a67 | |||
| d3000487f4 | |||
| 80254da1c1 | |||
| 73fc2d92f7 | |||
| f69aa33f3d | |||
| e16202c9f7 | |||
| 7453d0d274 | |||
| c102828c93 | |||
| afeb6dd318 | |||
| 6156623f0e | |||
| ccc06fcc3d | |||
| bbe3942056 | |||
| 49cea59b94 |
@@ -59,7 +59,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.13.1
|
||||
node-version: 20.9.0
|
||||
- name: Cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 16.13.1
|
||||
node-version: 20.9.0
|
||||
- name: Install Chrome
|
||||
uses: browser-actions/setup-chrome@latest
|
||||
- name: Cache node_modules
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@ Metrics/ParameterLists:
|
||||
Max: 10
|
||||
|
||||
Metrics/MethodLength:
|
||||
Max: 25
|
||||
Max: 30
|
||||
Exclude:
|
||||
- 'db/migrate/**'
|
||||
|
||||
@@ -44,7 +44,7 @@ Layout/LineLength:
|
||||
AllowedPatterns: ['\A\s*#']
|
||||
|
||||
Metrics/AbcSize:
|
||||
Max: 35
|
||||
Max: 40
|
||||
|
||||
Metrics/ModuleLength:
|
||||
Max: 500
|
||||
|
||||
+4
-5
@@ -2,9 +2,7 @@ FROM ruby:3.2.2-alpine3.18 as fonts
|
||||
|
||||
WORKDIR /fonts
|
||||
|
||||
RUN apk --no-cache add fontforge wget ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf . && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansArabic/hinted/ttf/NotoSansArabic-Regular.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
|
||||
|
||||
RUN fontforge -lang=py -c 'font1 = fontforge.open("LiberationSans-Regular.ttf"); font2 = fontforge.open("NotoSansArabic-Regular.ttf"); font1.mergeFonts(font2); font1.generate("LiberationSans-Regular.ttf")'
|
||||
RUN apk --no-cache add wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
|
||||
|
||||
FROM ruby:3.2.2-alpine3.18 as webpack
|
||||
|
||||
@@ -36,6 +34,7 @@ FROM ruby:3.2.2-alpine3.18 as app
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV BUNDLE_WITHOUT="development:test"
|
||||
ENV PIDFILE=/dev/null
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -43,7 +42,7 @@ RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips
|
||||
|
||||
COPY ./Gemfile ./Gemfile.lock ./
|
||||
|
||||
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle /usr/local/bundle/cache && find /usr/local/bundle -type d \( -name "rdoc" -o -name "spec" \) -exec rm -rf {} +
|
||||
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
|
||||
|
||||
COPY ./bin ./bin
|
||||
COPY ./app ./app
|
||||
@@ -55,7 +54,7 @@ COPY ./public ./public
|
||||
COPY ./tmp ./tmp
|
||||
COPY LICENSE README.md Rakefile config.ru .version ./
|
||||
|
||||
COPY --from=fonts /fonts/LiberationSans-Regular.ttf /fonts/LiberationSans-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
|
||||
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
|
||||
COPY --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN ln -s /fonts /app/public/fonts
|
||||
|
||||
@@ -26,12 +26,12 @@ gem 'pagy'
|
||||
gem 'pdf-reader'
|
||||
gem 'pg', require: false
|
||||
gem 'premailer-rails'
|
||||
gem 'pretender'
|
||||
gem 'puma'
|
||||
gem 'rack'
|
||||
gem 'rails'
|
||||
gem 'rails_autolink'
|
||||
gem 'rails-i18n'
|
||||
gem 'rollbar', require: ENV.key?('ROLLBAR_ACCESS_TOKEN')
|
||||
gem 'rotp'
|
||||
gem 'rqrcode'
|
||||
gem 'ruby-vips'
|
||||
|
||||
+4
-3
@@ -261,7 +261,7 @@ GEM
|
||||
signet (>= 0.16, < 2.a)
|
||||
hashdiff (1.1.0)
|
||||
hashery (2.1.2)
|
||||
hexapdf (0.34.1)
|
||||
hexapdf (0.37.1)
|
||||
cmdparse (~> 3.0, >= 3.0.3)
|
||||
geom2d (~> 0.4, >= 0.4.1)
|
||||
openssl (>= 2.2.1)
|
||||
@@ -354,6 +354,8 @@ GEM
|
||||
actionmailer (>= 3)
|
||||
net-smtp
|
||||
premailer (~> 1.7, >= 1.7.9)
|
||||
pretender (0.5.0)
|
||||
actionpack (>= 6.1)
|
||||
pry (0.14.2)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
@@ -431,7 +433,6 @@ GEM
|
||||
railties (>= 5.2)
|
||||
retriable (3.1.2)
|
||||
rexml (3.2.6)
|
||||
rollbar (3.4.2)
|
||||
rotp (6.3.0)
|
||||
rqrcode (2.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
@@ -596,13 +597,13 @@ DEPENDENCIES
|
||||
pdf-reader
|
||||
pg
|
||||
premailer-rails
|
||||
pretender
|
||||
pry-rails
|
||||
puma
|
||||
rack
|
||||
rails
|
||||
rails-i18n
|
||||
rails_autolink
|
||||
rollbar
|
||||
rotp
|
||||
rqrcode
|
||||
rspec-rails
|
||||
|
||||
@@ -33,17 +33,30 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
[](https://demo.docuseal.co)
|
||||
|
||||
## Features
|
||||
- [x] PDF form fields builder (WYSIWYG)
|
||||
- [x] 11 field types available (Signature, Date, File, Checkbox etc.)
|
||||
- [x] Multiple submitters per document
|
||||
- [x] Automated emails via SMTP
|
||||
- [x] Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
||||
- [x] Automatic PDF eSignature
|
||||
- [x] PDF signature verification
|
||||
- [x] Users management
|
||||
- [x] Mobile-optimized
|
||||
- [x] API and Webhooks for integrations
|
||||
- [x] Easy to deploy in minutes
|
||||
- PDF form fields builder (WYSIWYG)
|
||||
- 12 field types available (Signature, Date, File, Checkbox etc.)
|
||||
- Multiple submitters per document
|
||||
- Automated emails via SMTP
|
||||
- Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
||||
- Automatic PDF eSignature
|
||||
- PDF signature verification
|
||||
- Users management
|
||||
- Mobile-optimized
|
||||
- Signing available in 12 Languages
|
||||
- API and Webhooks for integrations
|
||||
- Easy to deploy in minutes
|
||||
|
||||
## Pro Features
|
||||
- Company logo and white-label
|
||||
- User roles
|
||||
- Automated reminders
|
||||
- Invitation and identify verification via SMS
|
||||
- SSO / SAML
|
||||
- Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api))
|
||||
- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
|
||||
- Embedded signing form ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
|
||||
- Embedded document form builder ([React](https://github.com/docusealco/docuseal-react), [Vue](https://github.com/docusealco/docuseal-vue) or [JavaScript](https://www.docuseal.co/docs/embedded))
|
||||
- [Learn more](https://www.docuseal.co/pricing)
|
||||
|
||||
## Deploy
|
||||
|
||||
@@ -77,11 +90,11 @@ sudo HOST=your-domain-name.com docker compose up
|
||||
```
|
||||
|
||||
## For Businesses
|
||||
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
|
||||
### Integrate seamless document signing into your web or mobile apps with DocuSeal
|
||||
|
||||
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
|
||||
|
||||
[Book a Meeting](https://calendly.com/kriti-docuseal/30min)
|
||||
[Book a Meeting](https://www.docuseal.co/contact)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Reporting a Vulnerability
|
||||
|
||||
If you come across any security concern or vulnarability, please report the information via email to security@docuseal.co instead of opening a GitHub issue. We will promptly respond and will collaborate with you to validate the issue, and resolve it ASAP.
|
||||
|
||||
**We have a bug bounty program to reward security researchers.**
|
||||
@@ -7,6 +7,7 @@ class AccountConfigsController < ApplicationController
|
||||
ALLOWED_KEYS = [
|
||||
AccountConfig::ALLOW_TYPED_SIGNATURE,
|
||||
AccountConfig::FORCE_MFA,
|
||||
AccountConfig::ALLOW_TO_RESUBMIT,
|
||||
AccountConfig::ESIGNING_PREFERENCE_KEY
|
||||
].freeze
|
||||
|
||||
|
||||
@@ -37,6 +37,18 @@ class AccountsController < ApplicationController
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
authorize!(:manage, current_account)
|
||||
|
||||
true_user.update!(locked_at: Time.current)
|
||||
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
:account_delete_button,
|
||||
html: helpers.tag.p('Your account removal request will be processed within 2 weeks. ' \
|
||||
'Please contact us if you want to keep your account.')
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_account
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class ActiveStorageBlobsProxyController < ApiBaseController
|
||||
include ActiveStorage::Streaming
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :set_cors_headers
|
||||
|
||||
def show
|
||||
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
|
||||
|
||||
if blob_uuid.blank? || purp != 'blob' || (exp && exp < Time.current.to_i)
|
||||
Rollbar.error('Blob not found') if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
blob = ActiveStorage::Blob.find_by!(uuid: blob_uuid)
|
||||
|
||||
if request.headers['Range'].present?
|
||||
send_blob_byte_range_data blob, request.headers['Range']
|
||||
else
|
||||
http_cache_forever public: true do
|
||||
response.headers['Accept-Ranges'] = 'bytes'
|
||||
response.headers['Content-Length'] = blob.byte_size.to_s
|
||||
|
||||
send_blob_stream blob, disposition: params[:disposition]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class ActiveStorageBlobsProxyLegacyController < ApiBaseController
|
||||
include ActiveStorage::Streaming
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def show
|
||||
Rollbar.info('Blob legacy') if defined?(Rollbar)
|
||||
|
||||
blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id] || params[:signed_id])
|
||||
|
||||
return head :not_found unless blob
|
||||
|
||||
is_permitted = blob.attachments.any? do |a|
|
||||
(current_user && a.record.account.id == current_user.account_id) ||
|
||||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
|
||||
a.name == 'logo'
|
||||
end
|
||||
|
||||
unless is_permitted
|
||||
Rollbar.error("Blob account not found: #{blob.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: 'Not authenticated' }, status: :unauthorized
|
||||
end
|
||||
|
||||
if request.headers['Range'].present?
|
||||
send_blob_byte_range_data blob, request.headers['Range']
|
||||
else
|
||||
http_cache_forever public: true do
|
||||
response.headers['Accept-Ranges'] = 'bytes'
|
||||
response.headers['Content-Length'] = blob.byte_size.to_s
|
||||
|
||||
send_blob_stream blob, disposition: params[:disposition]
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
end
|
||||
@@ -8,20 +8,26 @@ module Api
|
||||
DEFAULT_LIMIT = 10
|
||||
MAX_LIMIT = 100
|
||||
|
||||
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
|
||||
|
||||
wrap_parameters false
|
||||
|
||||
before_action :authenticate_user!
|
||||
check_authorization
|
||||
|
||||
rescue_from Params::BaseValidator::InvalidParameterError do |e|
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if Rails.env.production?
|
||||
rescue_from CanCan::AccessDenied do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :forbidden
|
||||
end
|
||||
|
||||
rescue_from JSON::ParserError do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: "JSON parse error: #{e.message}" }, status: :unprocessable_entity
|
||||
end
|
||||
@@ -44,7 +50,7 @@ module Api
|
||||
if request.headers['X-Auth-Token'].present?
|
||||
sha256 = Digest::SHA256.hexdigest(request.headers['X-Auth-Token'])
|
||||
|
||||
User.joins(:access_token).find_by(access_token: { sha256: })
|
||||
User.joins(:access_token).active.find_by(access_token: { sha256: })
|
||||
end
|
||||
|
||||
render json: { error: 'Not authenticated' }, status: :unauthorized unless current_user
|
||||
@@ -53,5 +59,13 @@ module Api
|
||||
def current_account
|
||||
current_user&.account
|
||||
end
|
||||
|
||||
def set_cors_headers
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
|
||||
headers['Access-Control-Allow-Headers'] = '*'
|
||||
headers['Access-Control-Max-Age'] = '1728000'
|
||||
headers['Access-Control-Allow-Credentials'] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -33,12 +33,18 @@ module Api
|
||||
def show
|
||||
submitters = @submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob)
|
||||
|
||||
serialized_submitters = submitters.map do |submitter|
|
||||
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
|
||||
|
||||
Submitters::SerializeForApi.call(submitter)
|
||||
submitters.each do |submitter|
|
||||
if submitter.completed_at? && submitter.documents_attachments.blank?
|
||||
submitter.documents_attachments = Submissions::EnsureResultGenerated.call(submitter)
|
||||
end
|
||||
end
|
||||
|
||||
if @submission.audit_trail_attachment.blank? && submitters.all?(&:completed_at?)
|
||||
@submission.audit_trail_attachment = Submissions::GenerateAuditTrail.call(@submission)
|
||||
end
|
||||
|
||||
serialized_submitters = submitters.map { |submitter| Submitters::SerializeForApi.call(submitter) }
|
||||
|
||||
json = @submission.as_json(
|
||||
serialize_params.deep_merge(
|
||||
include: { submission_events: { only: %i[id submitter_id event_type event_timestamp] } }
|
||||
@@ -63,10 +69,12 @@ module Api
|
||||
end
|
||||
|
||||
def create
|
||||
Params::SubmissionCreateValidator.call(params, dry_run: !Docuseal.multitenant?)
|
||||
|
||||
return render json: { error: 'Template not found' }, status: :unprocessable_entity if @template.nil?
|
||||
|
||||
if @template.fields.blank?
|
||||
Rollbar.error("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
|
||||
Rollbar.warning("Template does not contain fields: #{@template.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: 'Template does not contain fields' }, status: :unprocessable_entity
|
||||
end
|
||||
@@ -78,8 +86,16 @@ module Api
|
||||
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
submissions.each do |submission|
|
||||
if submission.submitters.all?(&:completed_at?) && submission.submitters.last
|
||||
ProcessSubmitterCompletionJob.perform_later(submission.submitters.last)
|
||||
end
|
||||
end
|
||||
|
||||
render json: submissions.flat_map(&:submitters)
|
||||
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
|
||||
rescue Submitters::NormalizeValues::BaseError => e
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@@ -94,7 +110,8 @@ module Api
|
||||
def create_submissions(template, params)
|
||||
is_send_email = !params[:send_email].in?(['false', false])
|
||||
|
||||
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
|
||||
if (emails = (params[:emails] || params[:email]).presence) &&
|
||||
(params[:submission].blank? && params[:submitters].blank?)
|
||||
Submissions.create_from_emails(template:,
|
||||
user: current_user,
|
||||
source: :api,
|
||||
@@ -115,8 +132,13 @@ module Api
|
||||
params:
|
||||
)
|
||||
|
||||
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments,
|
||||
submissions.flat_map(&:submitters))
|
||||
submitters = submissions.flat_map(&:submitters)
|
||||
|
||||
Submissions::NormalizeParamUtils.save_default_value_attachments!(attachments, submitters)
|
||||
|
||||
submitters.each do |submitter|
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'api_complete_form', request) if submitter.completed_at?
|
||||
end
|
||||
|
||||
submissions
|
||||
end
|
||||
@@ -129,8 +151,8 @@ module Api
|
||||
include: {
|
||||
submitters: { only: %i[id slug uuid name email phone
|
||||
completed_at opened_at sent_at
|
||||
created_at updated_at],
|
||||
methods: %i[status] },
|
||||
created_at updated_at external_id metadata],
|
||||
methods: %i[status application_key] },
|
||||
template: { only: %i[id name created_at updated_at] },
|
||||
created_by_user: { only: %i[id email first_name last_name] }
|
||||
}
|
||||
@@ -138,20 +160,27 @@ module Api
|
||||
end
|
||||
|
||||
def submissions_params
|
||||
key = params.key?(:submission) ? :submission : :submissions
|
||||
permitted_attrs = [
|
||||
:send_email, :send_sms, :bcc_completed, :completed_redirect_url,
|
||||
{
|
||||
message: %i[subject body],
|
||||
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key, :external_id,
|
||||
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
|
||||
fields: [%i[name default_value title description
|
||||
readonly validation_pattern invalid_message]] }]]
|
||||
}
|
||||
]
|
||||
|
||||
params.permit(
|
||||
key => [
|
||||
[:send_email, :send_sms, :bcc_completed, :completed_redirect_url, {
|
||||
message: %i[subject body],
|
||||
submitters: [[:send_email, :send_sms, :completed_redirect_url, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key,
|
||||
{ values: {}, readonly_fields: [], message: %i[subject body],
|
||||
fields: [%i[name default_value title description
|
||||
readonly validation_pattern invalid_message]] }]]
|
||||
}]
|
||||
]
|
||||
).fetch(key, [])
|
||||
if params.key?(:submitters)
|
||||
params.permit(*permitted_attrs)
|
||||
else
|
||||
key = params.key?(:submission) ? :submission : :submissions
|
||||
|
||||
params.permit(
|
||||
key => [permitted_attrs]
|
||||
).fetch(key, [])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,9 @@ module Api
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
submitter.opened_at = Time.current
|
||||
submitter.save
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(submitter, 'view_form', request)
|
||||
|
||||
SendFormViewedWebhookRequestJob.perform_later(submitter)
|
||||
|
||||
@@ -7,7 +7,8 @@ module Api
|
||||
def index
|
||||
submitters = Submitters.search(@submitters, params[:q])
|
||||
|
||||
submitters = submitters.where(application_key: params[:application_key]) if params[:application_key].present?
|
||||
submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present?
|
||||
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
|
||||
|
||||
submitters = paginate(
|
||||
@@ -44,7 +45,7 @@ module Api
|
||||
|
||||
Submissions::CreateFromSubmitters.maybe_set_template_fields(@submitter.submission,
|
||||
[normalized_params],
|
||||
submitter_uuid: @submitter.uuid)
|
||||
default_submitter_uuid: @submitter.uuid)
|
||||
|
||||
assign_submitter_attrs(@submitter, normalized_params)
|
||||
|
||||
@@ -54,6 +55,8 @@ module Api
|
||||
@submitter.save!
|
||||
|
||||
@submitter.submission.save!
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'api_complete_form', request) if @submitter.completed_at?
|
||||
end
|
||||
|
||||
if @submitter.completed_at?
|
||||
@@ -69,8 +72,9 @@ module Api
|
||||
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
|
||||
|
||||
submitter_params.permit(
|
||||
:send_email, :send_sms, :uuid, :name, :email, :role, :completed, :phone, :application_key,
|
||||
{ values: {}, readonly_fields: [], message: %i[subject body],
|
||||
:send_email, :send_sms, :uuid, :name, :email, :role,
|
||||
:completed, :phone, :application_key, :external_id,
|
||||
{ metadata: {}, values: {}, readonly_fields: [], message: %i[subject body],
|
||||
fields: [%i[name default_value readonly validation_pattern invalid_message]] }
|
||||
)
|
||||
end
|
||||
@@ -82,7 +86,9 @@ module Api
|
||||
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
|
||||
submitter.values = submitter.values.merge(attrs[:values].to_unsafe_h) if attrs[:values].present?
|
||||
submitter.completed_at = attrs[:completed] ? Time.current : submitter.completed_at
|
||||
submitter.application_key = attrs[:application_key] if attrs.key?(:application_key)
|
||||
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
|
||||
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
|
||||
submitter.metadata = attrs[:metadata] if attrs.key?(:metadata)
|
||||
|
||||
assign_submission_fields(submitter.submission)
|
||||
assign_preferences(submitter, attrs)
|
||||
|
||||
@@ -7,11 +7,13 @@ module Api
|
||||
def create
|
||||
authorize!(:manage, @template)
|
||||
|
||||
cloned_template = Templates::Clone.call(@template,
|
||||
author: current_user,
|
||||
name: params[:name],
|
||||
application_key: params[:application_key],
|
||||
folder_name: params[:folder_name])
|
||||
cloned_template = Templates::Clone.call(
|
||||
@template,
|
||||
author: current_user,
|
||||
name: params[:name],
|
||||
external_id: params[:external_id].presence || params[:application_key],
|
||||
folder_name: params[:folder_name]
|
||||
)
|
||||
|
||||
cloned_template.source = :api
|
||||
cloned_template.save!
|
||||
|
||||
@@ -5,16 +5,33 @@ module Api
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def index
|
||||
templates = Templates.search(@templates, params[:q])
|
||||
templates = filter_templates(@templates, params)
|
||||
|
||||
templates = params[:archived] ? templates.archived : templates.active
|
||||
templates = templates.where(application_key: params[:application_key]) if params[:application_key].present?
|
||||
templates = templates.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].present?
|
||||
templates = paginate(templates.preload(:author, :folder))
|
||||
|
||||
templates = paginate(templates.preload(:author, documents_attachments: :blob))
|
||||
schema_documents =
|
||||
ActiveStorage::Attachment.where(record_id: templates.map(&:id),
|
||||
record_type: 'Template',
|
||||
name: :documents,
|
||||
uuid: templates.flat_map { |t| t.schema.pluck('attachment_uuid') })
|
||||
.preload(:blob)
|
||||
|
||||
preview_image_attachments =
|
||||
ActiveStorage::Attachment.joins(:blob)
|
||||
.where(blob: { filename: '0.jpg' })
|
||||
.where(record_id: schema_documents.map(&:id),
|
||||
record_type: 'ActiveStorage::Attachment',
|
||||
name: :preview_images)
|
||||
.preload(:blob)
|
||||
|
||||
render json: {
|
||||
data: templates.as_json(serialize_params),
|
||||
data: templates.map do |t|
|
||||
Templates::SerializeForApi.call(
|
||||
t,
|
||||
schema_documents.select { |e| e.record_id == t.id },
|
||||
preview_image_attachments
|
||||
)
|
||||
end,
|
||||
pagination: {
|
||||
count: templates.size,
|
||||
next: templates.last&.id,
|
||||
@@ -24,44 +41,63 @@ module Api
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @template.as_json(serialize_params)
|
||||
render json: Templates::SerializeForApi.call(@template)
|
||||
end
|
||||
|
||||
def update
|
||||
if (folder_name = params.dig(:template, :folder_name))
|
||||
if (folder_name = params[:folder_name] || params.dig(:template, :folder_name))
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
|
||||
end
|
||||
|
||||
archived = params.key?(:archived) ? params[:archived] : params.dig(:template, :archived)
|
||||
|
||||
if archived.in?([true, false])
|
||||
@template.archived_at = archived == true ? Time.current : nil
|
||||
end
|
||||
|
||||
@template.update!(template_params)
|
||||
|
||||
render json: @template.as_json(only: %i[id updated_at])
|
||||
end
|
||||
|
||||
def destroy
|
||||
@template.update!(archived_at: Time.current)
|
||||
if params[:permanently] == 'true' && !Docuseal.multitenant?
|
||||
@template.destroy!
|
||||
else
|
||||
@template.update!(archived_at: Time.current)
|
||||
end
|
||||
|
||||
render json: @template.as_json(only: %i[id archived_at])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serialize_params
|
||||
{
|
||||
include: { author: { only: %i[id email first_name last_name] },
|
||||
documents: { only: %i[id uuid], methods: %i[url filename] } }
|
||||
}
|
||||
def filter_templates(templates, params)
|
||||
templates = Templates.search(templates, params[:q])
|
||||
templates = params[:archived] ? templates.archived : templates.active
|
||||
templates = templates.where(external_id: params[:application_key]) if params[:application_key].present?
|
||||
templates = templates.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||
templates = templates.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].present?
|
||||
|
||||
templates
|
||||
end
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
permit_params = [
|
||||
:name,
|
||||
schema: [%i[attachment_uuid name]],
|
||||
submitters: [%i[name uuid]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
|
||||
{ preferences: {},
|
||||
options: [%i[value uuid]],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
|
||||
)
|
||||
{ schema: [%i[attachment_uuid name]],
|
||||
submitters: [%i[name uuid]],
|
||||
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
|
||||
{ preferences: {},
|
||||
options: [%i[value uuid]],
|
||||
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
|
||||
]
|
||||
|
||||
if params.key?(:template)
|
||||
params.require(:template).permit(*permit_params)
|
||||
else
|
||||
params.permit(*permit_params)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -16,7 +16,7 @@ module Api
|
||||
render json: {
|
||||
schema:,
|
||||
documents: documents.as_json(
|
||||
methods: [:metadata],
|
||||
methods: %i[metadata signed_uuid],
|
||||
include: {
|
||||
preview_images: { methods: %i[url metadata filename] }
|
||||
}
|
||||
|
||||
@@ -16,15 +16,17 @@ class ApplicationController < ActionController::Base
|
||||
:current_account,
|
||||
:svg_icon
|
||||
|
||||
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
|
||||
|
||||
rescue_from Pagy::OverflowError do
|
||||
redirect_to request.path
|
||||
end
|
||||
|
||||
if Rails.env.production?
|
||||
rescue_from CanCan::AccessDenied do |e|
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
redirect_back(fallback_location: root_path, alert: e.message)
|
||||
redirect_to root_path, alert: e.message
|
||||
end
|
||||
end
|
||||
|
||||
@@ -32,6 +34,15 @@ class ApplicationController < ActionController::Base
|
||||
Docuseal.default_url_options
|
||||
end
|
||||
|
||||
def impersonate_user(user)
|
||||
raise ArgumentError unless user
|
||||
raise Pretender::Error unless true_user
|
||||
|
||||
@impersonated_user = user
|
||||
|
||||
request.session[:impersonated_user_id] = user.uuid
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def with_browser_locale(&)
|
||||
|
||||
@@ -5,9 +5,9 @@ class ConsoleRedirectController < ApplicationController
|
||||
skip_authorization_check
|
||||
|
||||
def index
|
||||
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if current_user.blank?
|
||||
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
|
||||
|
||||
auth = JsonWebToken.encode(uuid: current_user.uuid,
|
||||
auth = JsonWebToken.encode(uuid: true_user.uuid,
|
||||
scope: :console,
|
||||
exp: 1.minute.from_now.to_i)
|
||||
|
||||
|
||||
@@ -52,7 +52,13 @@ class DashboardController < ApplicationController
|
||||
|
||||
def filter_templates(templates)
|
||||
rel = templates.active.preload(:author).order(id: :desc)
|
||||
rel = rel.where(folder_id: current_account.default_template_folder.id) if params[:q].blank?
|
||||
|
||||
if params[:q].blank?
|
||||
shared_template_ids =
|
||||
TemplateSharing.where(account_id: [current_account.id, TemplateSharing::ALL_ID]).select(:template_id)
|
||||
|
||||
rel = rel.where(folder_id: current_account.default_template_folder.id).or(rel.where(id: shared_template_ids))
|
||||
end
|
||||
|
||||
Templates.search(rel, params[:q])
|
||||
end
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EncryptedUserConfigsController < ApplicationController
|
||||
load_and_authorize_resource :encrypted_user_config
|
||||
|
||||
def destroy
|
||||
@encrypted_user_config.destroy!
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,9 @@ class PreviewDocumentPageController < ActionController::API
|
||||
FORMAT = Templates::ProcessDocument::FORMAT
|
||||
|
||||
def show
|
||||
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid])
|
||||
attachment_uuid = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid], purpose: :attachment)
|
||||
|
||||
attachment = ActiveStorage::Attachment.find_by(uuid: attachment_uuid) if attachment_uuid
|
||||
|
||||
return head :not_found unless attachment
|
||||
|
||||
@@ -21,9 +23,10 @@ class PreviewDocumentPageController < ActionController::API
|
||||
find_or_create_document_tempfile_path(attachment)
|
||||
end
|
||||
|
||||
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
||||
preview_image =
|
||||
Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
|
||||
|
||||
render plain: io.tap(&:rewind).read, content_type: 'image/jpeg'
|
||||
redirect_to preview_image.url, allow_other_host: true
|
||||
end
|
||||
|
||||
def find_or_create_document_tempfile_path(attachment)
|
||||
|
||||
@@ -12,13 +12,16 @@ class SendSubmissionEmailController < ApplicationController
|
||||
def create
|
||||
@submitter =
|
||||
if params[:template_slug]
|
||||
Submitter.joins(submission: :template).find_by!(email: params[:email],
|
||||
Submitter.joins(submission: :template).find_by!(email: params[:email].to_s.downcase,
|
||||
template: { slug: params[:template_slug] })
|
||||
elsif params[:submission_slug]
|
||||
Submitter.joins(:submission).find_by!(email: params[:email].to_s.downcase,
|
||||
submission: { slug: params[:submission_slug] })
|
||||
else
|
||||
Submitter.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
|
||||
SubmitterMailer.documents_copy_email(@submitter).deliver_later!
|
||||
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
|
||||
|
||||
respond_to do |f|
|
||||
f.html { redirect_to success_send_submission_email_index_path }
|
||||
|
||||
@@ -4,12 +4,14 @@ class SessionsController < Devise::SessionsController
|
||||
before_action :configure_permitted_parameters
|
||||
|
||||
def create
|
||||
if Docuseal.multitenant? && !User.exists?(email: sign_in_params[:email])
|
||||
email = sign_in_params[:email].to_s.downcase
|
||||
|
||||
if Docuseal.multitenant? && !User.exists?(email:)
|
||||
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
|
||||
notice: 'Create a new account'
|
||||
end
|
||||
|
||||
if User.exists?(email: sign_in_params[:email], otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
|
||||
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_entity
|
||||
end
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@ class StartFormController < ApplicationController
|
||||
before_action :load_template
|
||||
|
||||
def show
|
||||
@submitter = @template.submissions.new.submitters.new(uuid: @template.submitters.first['uuid'])
|
||||
@submitter = @template.submissions.new(account_id: @template.account_id)
|
||||
.submitters.new(uuid: @template.submitters.first['uuid'])
|
||||
end
|
||||
|
||||
def update
|
||||
return redirect_to start_form_path(@template.slug) if @template.archived_at?
|
||||
|
||||
@submitter = Submitter.where(submission: @template.submissions.where(archived_at: nil))
|
||||
.order(id: :desc)
|
||||
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
|
||||
@@ -57,7 +60,8 @@ class StartFormController < ApplicationController
|
||||
ip: request.remote_ip,
|
||||
ua: request.user_agent,
|
||||
values: resubmit_submitter&.preferences&.fetch('default_values', nil) || {},
|
||||
preferences: resubmit_submitter&.preferences.presence || { 'send_email' => true }
|
||||
preferences: resubmit_submitter&.preferences.presence || { 'send_email' => true },
|
||||
metadata: resubmit_submitter&.metadata.presence || {}
|
||||
)
|
||||
|
||||
if submitter.values.present?
|
||||
@@ -67,6 +71,7 @@ class StartFormController < ApplicationController
|
||||
end
|
||||
|
||||
submitter.submission ||= Submission.new(template:,
|
||||
account_id: template.account_id,
|
||||
template_submitters: template.submitters,
|
||||
source: :link)
|
||||
|
||||
|
||||
@@ -6,23 +6,8 @@ class SubmissionsController < ApplicationController
|
||||
|
||||
load_and_authorize_resource :submission, only: %i[show destroy]
|
||||
|
||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||
|
||||
def show
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@submission],
|
||||
associations: [:template, { template_schema_documents: :blob }]
|
||||
).call
|
||||
|
||||
total_pages =
|
||||
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
|
||||
|
||||
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: @submission.template_schema_documents,
|
||||
associations: [:blob, { preview_images_attachments: :blob }]
|
||||
).call
|
||||
end
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
render :show, layout: 'plain'
|
||||
end
|
||||
@@ -75,6 +60,6 @@ class SubmissionsController < ApplicationController
|
||||
end
|
||||
|
||||
def load_template
|
||||
@template = current_account.templates.find(params[:template_id])
|
||||
@template = Template.accessible_by(current_ability).find(params[:template_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,8 +4,20 @@ class SubmissionsDownloadController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
TTL = 40.minutes
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
submitter = Submitter.find_by(slug: params[:submitter_slug])
|
||||
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
signature_valid =
|
||||
if submitter&.slug == params[:submitter_slug]
|
||||
true
|
||||
else
|
||||
submitter = nil
|
||||
end
|
||||
|
||||
submitter ||= Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
Submissions::EnsureResultGenerated.call(submitter)
|
||||
|
||||
@@ -13,11 +25,26 @@ class SubmissionsDownloadController < ApplicationController
|
||||
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
urls =
|
||||
Submitters.select_attachments_for_download(last_submitter).map do |attachment|
|
||||
helpers.rails_blob_url(attachment)
|
||||
end
|
||||
return head :not_found unless last_submitter.completed_at?
|
||||
|
||||
render json: urls
|
||||
if last_submitter.completed_at < TTL.ago && !signature_valid && !current_user_submitter?(last_submitter)
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
render json: build_urls(last_submitter)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def current_user_submitter?(submitter)
|
||||
current_user && current_user.account.submitters.exists?(id: submitter.id)
|
||||
end
|
||||
|
||||
def build_urls(submitter)
|
||||
Submitters.select_attachments_for_download(submitter).map do |attachment|
|
||||
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at: FILES_TTL.minutes.from_now.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,26 +4,48 @@ class SubmissionsPreviewController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
PRELOAD_ALL_PAGES_AMOUNT = 200
|
||||
TTL = 40.minutes
|
||||
|
||||
def show
|
||||
@submission = Submission.find_by!(slug: params[:slug])
|
||||
submitter = Submitter.find_signed(params[:sig], purpose: :download_completed) if params[:sig].present?
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@submission],
|
||||
associations: [:template, { template_schema_documents: :blob }]
|
||||
).call
|
||||
signature_valid =
|
||||
if submitter && submitter.submission.slug == params[:slug]
|
||||
@submission = submitter.submission
|
||||
|
||||
total_pages =
|
||||
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
|
||||
true
|
||||
end
|
||||
|
||||
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: @submission.template_schema_documents,
|
||||
associations: [:blob, { preview_images_attachments: :blob }]
|
||||
).call
|
||||
@submission ||= Submission.find_by!(slug: params[:slug])
|
||||
|
||||
if !@submission.submitters.all?(&:completed_at?) && current_user.blank?
|
||||
raise ActionController::RoutingError, 'Not Found'
|
||||
end
|
||||
|
||||
if !submission_valid_ttl?(@submission) && !signature_valid
|
||||
Rollbar.info("TTL: #{@submission.id}") if defined?(Rollbar)
|
||||
|
||||
return redirect_to submissions_preview_completed_path(@submission.slug)
|
||||
end
|
||||
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
render 'submissions/show', layout: 'plain'
|
||||
end
|
||||
|
||||
def completed
|
||||
@submission = Submission.find_by!(slug: params[:submissions_preview_slug])
|
||||
|
||||
render :completed, layout: 'form'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def submission_valid_ttl?(submission)
|
||||
return true if current_user && current_user.account.submissions.exists?(id: submission.id)
|
||||
|
||||
last_submitter = submission.submitters.select(&:completed_at?).max_by(&:completed_at)
|
||||
|
||||
last_submitter && last_submitter.completed_at > TTL.ago
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,9 +11,7 @@ class SubmittersSendEmailController < ApplicationController
|
||||
alert: 'Email has been sent already.')
|
||||
end
|
||||
|
||||
SubmitterMailer.invitation_email(@submitter).deliver_later!
|
||||
|
||||
SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email')
|
||||
SendSubmitterInvitationEmailJob.perform_later('submitter_id' => @submitter.id)
|
||||
|
||||
@submitter.sent_at ||= Time.current
|
||||
@submitter.save!
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplateSharingsTestingController < ApplicationController
|
||||
load_and_authorize_resource :template, parent: true
|
||||
|
||||
before_action do
|
||||
authorize!(:manage, TemplateSharing.new(template: @template))
|
||||
end
|
||||
|
||||
def create
|
||||
testing_account = Accounts.find_or_create_testing_user(true_user.account).account
|
||||
|
||||
if params[:value] == '1'
|
||||
TemplateSharing.create!(ability: :manage, account: testing_account, template: @template)
|
||||
else
|
||||
TemplateSharing.find_by(template: @template, account: testing_account)&.destroy!
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesCodeModalController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show; end
|
||||
end
|
||||
@@ -6,7 +6,7 @@ class TemplatesController < ApplicationController
|
||||
before_action :load_base_template, only: %i[new create]
|
||||
|
||||
def show
|
||||
submissions = @template.submissions
|
||||
submissions = @template.submissions.accessible_by(current_ability)
|
||||
submissions = submissions.active if @template.archived_at.blank?
|
||||
submissions = Submissions.search(submissions, params[:q])
|
||||
|
||||
@@ -33,7 +33,7 @@ class TemplatesController < ApplicationController
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: [:metadata],
|
||||
methods: %i[metadata signed_uuid],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
@@ -47,15 +47,21 @@ class TemplatesController < ApplicationController
|
||||
name: params.dig(:template, :name),
|
||||
folder_name: params[:folder_name])
|
||||
else
|
||||
@template.account = current_account
|
||||
@template.author = current_user
|
||||
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||
end
|
||||
|
||||
if params[:account_id].present? && authorized_clone_account_id?(params[:account_id])
|
||||
@template.account_id = params[:account_id]
|
||||
@template.folder = @template.account.default_template_folder
|
||||
else
|
||||
@template.account = current_account
|
||||
end
|
||||
|
||||
if @template.save
|
||||
Templates::CloneAttachments.call(template: @template, original_template: @base_template) if @base_template
|
||||
|
||||
redirect_to edit_template_path(@template)
|
||||
maybe_redirect_to_template(@template)
|
||||
else
|
||||
render turbo_stream: turbo_stream.replace(:modal, template: 'templates/new'), status: :unprocessable_entity
|
||||
end
|
||||
@@ -80,6 +86,18 @@ class TemplatesController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def authorized_clone_account_id?(account_id)
|
||||
true_user.account_id.to_s == account_id.to_s || true_user.account.linked_accounts.exists?(id: account_id)
|
||||
end
|
||||
|
||||
def maybe_redirect_to_template(template)
|
||||
if template.account == current_account
|
||||
redirect_to(edit_template_path(@template))
|
||||
else
|
||||
redirect_back(fallback_location: root_path, notice: 'Template has been clonned')
|
||||
end
|
||||
end
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(:name)
|
||||
end
|
||||
@@ -87,6 +105,6 @@ class TemplatesController < ApplicationController
|
||||
def load_base_template
|
||||
return if params[:base_template_id].blank?
|
||||
|
||||
@base_template = current_account.templates.find_by(id: params[:base_template_id])
|
||||
@base_template = Template.accessible_by(current_ability).find_by(id: params[:base_template_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TemplatesPreviewController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def show
|
||||
ActiveRecord::Associations::Preloader.new(
|
||||
records: [@template],
|
||||
associations: [schema_documents: { preview_images_attachments: :blob }]
|
||||
).call
|
||||
|
||||
@template_data =
|
||||
@template.as_json.merge(
|
||||
documents: @template.schema_documents.as_json(
|
||||
methods: %i[metadata signed_uuid],
|
||||
include: { preview_images: { methods: %i[url metadata filename] } }
|
||||
)
|
||||
).to_json
|
||||
|
||||
render :show, layout: 'plain'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TestingAccountsController < ApplicationController
|
||||
skip_authorization_check only: :destroy
|
||||
|
||||
def show
|
||||
authorize!(:manage, current_account)
|
||||
authorize!(:manage, current_user)
|
||||
|
||||
impersonate_user(Accounts.find_or_create_testing_user(true_user.account))
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
|
||||
def destroy
|
||||
stop_impersonating_user
|
||||
|
||||
redirect_back(fallback_location: root_path)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class TestingApiSettingsController < ApplicationController
|
||||
def index
|
||||
authorize!(:manage, current_user.access_token)
|
||||
|
||||
@webhook_config =
|
||||
current_account.encrypted_configs.find_or_initialize_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
||||
|
||||
authorize!(:manage, @webhook_config)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class UserConfigsController < ApplicationController
|
||||
before_action :load_user_config
|
||||
authorize_resource :user_config
|
||||
|
||||
ALLOWED_KEYS = [
|
||||
UserConfig::RECEIVE_COMPLETED_EMAIL
|
||||
].freeze
|
||||
|
||||
def create
|
||||
@user_config.update!(user_config_params)
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_user_config
|
||||
return head :not_found unless ALLOWED_KEYS.include?(user_config_params[:key])
|
||||
|
||||
@user_config =
|
||||
UserConfig.find_or_initialize_by(user: current_user, key: user_config_params[:key])
|
||||
end
|
||||
|
||||
def user_config_params
|
||||
params.required(:user_config).permit!.tap do |attrs|
|
||||
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -88,7 +88,10 @@ window.customElements.define('template-builder', class extends HTMLElement {
|
||||
backgroundColor: '#faf7f5',
|
||||
withPhone: this.dataset.withPhone === 'true',
|
||||
withLogo: this.dataset.withLogo !== 'false',
|
||||
editable: this.dataset.editable !== 'false',
|
||||
withPayment: this.dataset.withPayment === 'true',
|
||||
withFormula: this.dataset.withFormula === 'true',
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||
isDirectUpload: this.dataset.isDirectUpload === 'true'
|
||||
})
|
||||
|
||||
@@ -75,16 +75,16 @@ button[disabled] .enabled {
|
||||
@apply select base-input w-full font-normal;
|
||||
}
|
||||
|
||||
.tooltip-bottom-start:before {
|
||||
transform: translateX(-30%);
|
||||
.tooltip-bottom-end:before {
|
||||
transform: translateX(-95%);
|
||||
top: var(--tooltip-offset);
|
||||
left: 100%;
|
||||
right: auto;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.tooltip-bottom-start:after {
|
||||
transform: translateX(-75%);
|
||||
.tooltip-bottom-end:after {
|
||||
transform: translateX(-25%);
|
||||
border-color: transparent transparent var(--tooltip-color) transparent;
|
||||
top: var(--tooltip-tail-offset);
|
||||
left: 50%;
|
||||
|
||||
@@ -15,7 +15,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
|
||||
goToLast: this.dataset.goToLast === 'true',
|
||||
isDemo: this.dataset.isDemo === 'true',
|
||||
attribution: this.dataset.attribution !== 'false',
|
||||
withConfetti: true,
|
||||
withConfetti: this.dataset.withConfetti !== 'false',
|
||||
withTypedSignature: this.dataset.withTypedSignature !== 'false',
|
||||
values: reactive(JSON.parse(this.dataset.values)),
|
||||
completedButton: JSON.parse(this.dataset.completedButton),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex absolute lg:text-base"
|
||||
class="flex absolute lg:text-base -outline-offset-1"
|
||||
dir="auto"
|
||||
:style="computedStyle"
|
||||
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed border-2 z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
|
||||
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
|
||||
>
|
||||
<div
|
||||
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
|
||||
@@ -81,7 +81,8 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.type === 'checkbox'"
|
||||
class="w-full p-[0.2vw] flex items-center justify-center"
|
||||
class="w-full p-[1px] flex items-center justify-center"
|
||||
@click="$event.target.querySelector('input')?.click()"
|
||||
>
|
||||
<input
|
||||
v-if="submittable"
|
||||
@@ -100,14 +101,15 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.type === 'radio' && area.option_uuid"
|
||||
class="w-full p-[0.2vw] flex items-center justify-center"
|
||||
class="w-full p-[1px] flex items-center justify-center"
|
||||
@click="$event.target.querySelector('input')?.click()"
|
||||
>
|
||||
<input
|
||||
v-if="submittable"
|
||||
type="radio"
|
||||
:value="false"
|
||||
class="aspect-square base-radio"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
class="aspect-square checked:checkbox checked:checkbox-xs"
|
||||
:class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
:checked="!!modelValue && modelValue === optionValue(option)"
|
||||
@click="$emit('update:model-value', optionValue(option))"
|
||||
>
|
||||
@@ -119,7 +121,8 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="field.type === 'multiple' && area.option_uuid"
|
||||
class="w-full p-[0.2vw] flex items-center justify-center"
|
||||
class="w-full p-[1px] flex items-center justify-center"
|
||||
@click="$event.target.querySelector('input')?.click()"
|
||||
>
|
||||
<input
|
||||
v-if="submittable"
|
||||
@@ -170,7 +173,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FieldArea',
|
||||
@@ -233,20 +236,21 @@ export default {
|
||||
computed: {
|
||||
fieldNames () {
|
||||
return {
|
||||
text: 'Text',
|
||||
cells: 'Text',
|
||||
signature: 'Signature',
|
||||
date: 'Date',
|
||||
image: 'Image',
|
||||
stamp: 'Stamp',
|
||||
initials: 'Initials',
|
||||
file: 'File',
|
||||
select: 'Select',
|
||||
checkbox: 'Checkbox',
|
||||
radio: 'Radio',
|
||||
multiple: 'Multiple Select',
|
||||
phone: 'Phone',
|
||||
payment: 'Payment'
|
||||
text: this.t('text'),
|
||||
signature: this.t('signature'),
|
||||
initials: this.t('initials'),
|
||||
date: this.t('date'),
|
||||
number: this.t('number'),
|
||||
image: this.t('image'),
|
||||
file: this.t('file'),
|
||||
select: this.t('select'),
|
||||
checkbox: this.t('checkbox'),
|
||||
multiple: this.t('multiple'),
|
||||
radio: this.t('radio'),
|
||||
cells: this.t('cells'),
|
||||
stamp: this.t('stamp'),
|
||||
payment: this.t('payment'),
|
||||
phone: this.t('phone')
|
||||
}
|
||||
},
|
||||
option () {
|
||||
@@ -257,6 +261,7 @@ export default {
|
||||
text: IconTextSize,
|
||||
signature: IconWritingSign,
|
||||
date: IconCalendarEvent,
|
||||
number: IconSquareNumber1,
|
||||
image: IconPhoto,
|
||||
initials: IconLetterCaseUpper,
|
||||
file: IconPaperclip,
|
||||
@@ -304,7 +309,7 @@ export default {
|
||||
formattedDate () {
|
||||
if (this.field.type === 'date' && this.modelValue) {
|
||||
return this.formatDate(
|
||||
new Date(this.modelValue),
|
||||
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
|
||||
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
>
|
||||
</template>
|
||||
<FileDropzone
|
||||
:message="`Upload ${field.name || 'Attachments'}${field.required ? '' : ' (optional)'}`"
|
||||
:message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:submitter-slug="submitterSlug"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:multiple="true"
|
||||
@@ -62,6 +62,7 @@ export default {
|
||||
IconPaperclip,
|
||||
IconTrashX
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<label
|
||||
:for="field.uuid"
|
||||
class="label text-2xl"
|
||||
>{{ field.name || t('date') }}
|
||||
>{{ field.name && showFieldNames ? field.name : t('date') }}
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</label>
|
||||
<button
|
||||
@@ -46,6 +46,11 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
|
||||
@@ -4,15 +4,20 @@
|
||||
:steps="stepFields"
|
||||
:values="values"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:with-label="!isAnonymousChecboxes"
|
||||
:with-label="!isAnonymousChecboxes && showFieldNames"
|
||||
:current-step="currentStepFields"
|
||||
@focus-step="[saveStep(), goToStep($event, false, true), currentField.type !== 'checkbox' ? isFormVisible = true : '']"
|
||||
/>
|
||||
<FormulaFieldAreas
|
||||
v-if="formulaFields.length"
|
||||
:fields="formulaFields"
|
||||
:values="values"
|
||||
/>
|
||||
<button
|
||||
v-if="!isFormVisible"
|
||||
id="expand_form_button"
|
||||
class="btn btn-neutral flex text-white absolute rounded-none border-x-0 md:border md:rounded-full bottom-0 w-full md:mb-4 text-base"
|
||||
@click.prevent="isFormVisible = true"
|
||||
@click.prevent="[isFormVisible = true, scrollIntoField(currentField)]"
|
||||
>
|
||||
{{ t('submit_form') }}
|
||||
<IconArrowsDiagonal
|
||||
@@ -32,7 +37,7 @@
|
||||
v-if="!isCompleted"
|
||||
id="minimize_form_button"
|
||||
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block"
|
||||
title="Minimize"
|
||||
:title="t('minimize')"
|
||||
@click.prevent="isFormVisible = false"
|
||||
>
|
||||
<IconArrowsDiagonalMinimize2
|
||||
@@ -40,13 +45,16 @@
|
||||
:height="20"
|
||||
/>
|
||||
</button>
|
||||
<div>
|
||||
<div
|
||||
:class="{ 'md:px-4': isBreakpointMd }"
|
||||
>
|
||||
<form
|
||||
v-if="!isCompleted"
|
||||
ref="form"
|
||||
:action="submitPath"
|
||||
method="post"
|
||||
:class="{ 'md:mx-16': isBreakpointMd }"
|
||||
class="mx-auto"
|
||||
:style="{ maxWidth: isBreakpointMd ? '582px' : '' }"
|
||||
@submit.prevent="submitStep"
|
||||
>
|
||||
<input
|
||||
@@ -64,20 +72,30 @@
|
||||
<TextStep
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:show-field-names="showFieldNames"
|
||||
:field="currentField"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
/>
|
||||
</div>
|
||||
<NumberStep
|
||||
v-else-if="currentField.type === 'number'"
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:show-field-names="showFieldNames"
|
||||
:field="currentField"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
/>
|
||||
<DateStep
|
||||
v-else-if="currentField.type === 'date'"
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:show-field-names="showFieldNames"
|
||||
:field="currentField"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
/>
|
||||
<div v-else-if="currentField.type === 'select'">
|
||||
<label
|
||||
v-if="currentField.name"
|
||||
v-if="showFieldNames && currentField.name"
|
||||
:for="currentField.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
@@ -116,15 +134,28 @@
|
||||
</div>
|
||||
<div v-else-if="currentField.type === 'radio'">
|
||||
<label
|
||||
v-if="currentField.name"
|
||||
v-if="showFieldNames && currentField.name"
|
||||
:for="currentField.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ currentField.name }}
|
||||
<template v-if="!currentField.required">({{ t('optional') }})</template>
|
||||
</label>
|
||||
<div class="flex w-full">
|
||||
<div class="space-y-3.5 mx-auto">
|
||||
<div class="flex w-full max-h-44 overflow-y-auto">
|
||||
<div
|
||||
v-if="!showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4)"
|
||||
class="text-xl px-1"
|
||||
>
|
||||
<span
|
||||
@click="scrollIntoField(currentField)"
|
||||
>
|
||||
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="space-y-3.5 mx-auto"
|
||||
:class="{ hidden: !showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4) }"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in currentField.options"
|
||||
:key="option.uuid"
|
||||
@@ -154,6 +185,8 @@
|
||||
v-else-if="currentField.type === 'multiple'"
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:show-field-names="showFieldNames"
|
||||
:is-last-step="stepFields.length === currentStep + 1"
|
||||
:field="currentField"
|
||||
/>
|
||||
<div
|
||||
@@ -168,7 +201,7 @@
|
||||
<div
|
||||
class="space-y-3.5 mx-auto"
|
||||
>
|
||||
<template v-if="isAnonymousChecboxes">
|
||||
<template v-if="isAnonymousChecboxes || !showFieldNames">
|
||||
<span class="text-xl">
|
||||
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
|
||||
</span>
|
||||
@@ -228,6 +261,7 @@
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:submitter-slug="submitterSlug"
|
||||
:show-field-names="showFieldNames"
|
||||
@attached="[attachments.push($event), scrollIntoField(currentField)]"
|
||||
/>
|
||||
<SignatureStep
|
||||
@@ -236,11 +270,12 @@
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
:previous-value="previousSignatureValue"
|
||||
:previous-value="previousSignatureValueFor(currentField)"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:with-typed-signature="withTypedSignature"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:submitter-slug="submitterSlug"
|
||||
:show-field-names="showFieldNames"
|
||||
@attached="attachments.push($event)"
|
||||
@start="scrollIntoField(currentField)"
|
||||
@minimize="isFormVisible = false"
|
||||
@@ -254,6 +289,7 @@
|
||||
:previous-value="previousInitialsValue"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:show-field-names="showFieldNames"
|
||||
:submitter-slug="submitterSlug"
|
||||
@attached="attachments.push($event)"
|
||||
@start="scrollIntoField(currentField)"
|
||||
@@ -276,6 +312,7 @@
|
||||
:key="currentField.uuid"
|
||||
v-model="values[currentField.uuid]"
|
||||
:field="currentField"
|
||||
:show-field-names="showFieldNames"
|
||||
:default-value="submitter.phone"
|
||||
:submitter-slug="submitterSlug"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
@@ -364,6 +401,7 @@
|
||||
|
||||
<script>
|
||||
import FieldAreas from './areas'
|
||||
import FormulaFieldAreas from './formula_areas'
|
||||
import ImageStep from './image_step'
|
||||
import SignatureStep from './signature_step'
|
||||
import InitialsStep from './initials_step'
|
||||
@@ -372,11 +410,30 @@ import MultiSelectStep from './multi_select_step'
|
||||
import PhoneStep from './phone_step'
|
||||
import PaymentStep from './payment_step'
|
||||
import TextStep from './text_step'
|
||||
import NumberStep from './number_step'
|
||||
import DateStep from './date_step'
|
||||
import FormCompleted from './completed'
|
||||
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||
import AppearsOn from './appears_on'
|
||||
import { t } from './i18n'
|
||||
import i18n from './i18n'
|
||||
|
||||
const isEmpty = (obj) => {
|
||||
if (obj == null) return true
|
||||
|
||||
if (Array.isArray(obj) || typeof obj === 'string') {
|
||||
return obj.length === 0
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
return Object.keys(obj).length === 0
|
||||
}
|
||||
|
||||
if (obj === false) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'SubmissionForm',
|
||||
@@ -392,6 +449,8 @@ export default {
|
||||
DateStep,
|
||||
IconArrowsDiagonal,
|
||||
TextStep,
|
||||
NumberStep,
|
||||
FormulaFieldAreas,
|
||||
PhoneStep,
|
||||
PaymentStep,
|
||||
IconArrowsDiagonalMinimize2,
|
||||
@@ -401,6 +460,7 @@ export default {
|
||||
return {
|
||||
baseUrl: this.baseUrl,
|
||||
scrollIntoArea: this.scrollIntoArea,
|
||||
scrollIntoField: this.scrollIntoField,
|
||||
t: this.t
|
||||
}
|
||||
},
|
||||
@@ -426,11 +486,21 @@ export default {
|
||||
return () => {}
|
||||
}
|
||||
},
|
||||
expand: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withConfetti: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
withTypedSignature: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -481,11 +551,21 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
i18n: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
withSendCopyButton: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -510,7 +590,7 @@ export default {
|
||||
data () {
|
||||
return {
|
||||
isCompleted: false,
|
||||
isFormVisible: true,
|
||||
isFormVisible: this.expand,
|
||||
showFillAllRequiredFields: false,
|
||||
currentStep: 0,
|
||||
isSubmitting: false,
|
||||
@@ -522,6 +602,9 @@ export default {
|
||||
currentStepFields () {
|
||||
return this.stepFields[this.currentStep]
|
||||
},
|
||||
browserLanguage () {
|
||||
return (navigator.language || navigator.userLanguage || 'en').split('-')[0]
|
||||
},
|
||||
queryParams () {
|
||||
return new URLSearchParams(window.location.search)
|
||||
},
|
||||
@@ -531,18 +614,13 @@ export default {
|
||||
submitterSlug () {
|
||||
return this.submitter.slug
|
||||
},
|
||||
previousSignatureValue () {
|
||||
const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid])
|
||||
|
||||
return this.values[signatureField?.uuid]
|
||||
},
|
||||
previousInitialsValue () {
|
||||
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
|
||||
|
||||
return this.values[initialsField?.uuid]
|
||||
},
|
||||
isAnonymousChecboxes () {
|
||||
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
|
||||
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name && !e.required) && this.currentStepFields.length > 4
|
||||
},
|
||||
isButtonDisabled () {
|
||||
if (this.recalculateButtonDisabledKey) {
|
||||
@@ -570,6 +648,9 @@ export default {
|
||||
return acc
|
||||
}, [])
|
||||
},
|
||||
formulaFields () {
|
||||
return this.fields.filter((f) => f.preferences?.formula)
|
||||
},
|
||||
attachmentsIndex () {
|
||||
return this.attachments.reduce((acc, a) => {
|
||||
acc[a.uuid] = a
|
||||
@@ -581,12 +662,17 @@ export default {
|
||||
return `/s/${this.submitterSlug}`
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
expand (value) {
|
||||
this.isFormVisible = value
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.submittedValues = JSON.parse(JSON.stringify(this.values))
|
||||
|
||||
this.fields.forEach((field) => {
|
||||
if (field.default_value && !field.readonly) {
|
||||
this.values[field.uuid] = field.default_value
|
||||
this.values[field.uuid] ||= field.default_value
|
||||
}
|
||||
})
|
||||
|
||||
@@ -639,7 +725,9 @@ export default {
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
t,
|
||||
t (key) {
|
||||
return this.i18n[key] || i18n[this.language?.toLowerCase()]?.[key] || i18n[this.browserLanguage]?.[key] || i18n.en[key] || key
|
||||
},
|
||||
maybeTrackEmailClick () {
|
||||
const { queryParams } = this
|
||||
|
||||
@@ -699,6 +787,13 @@ export default {
|
||||
})
|
||||
})
|
||||
},
|
||||
previousSignatureValueFor (field) {
|
||||
const signatureField = [...this.fields].reverse().find((f) =>
|
||||
f.type === 'signature' && field.preferences?.format === f.preferences?.format && !!this.values[f.uuid]
|
||||
)
|
||||
|
||||
return this.values[signatureField?.uuid]
|
||||
},
|
||||
goToStep (step, scrollToArea = false, clickUpload = false) {
|
||||
this.currentStep = this.stepFields.indexOf(step)
|
||||
this.showFillAllRequiredFields = false
|
||||
@@ -710,7 +805,7 @@ export default {
|
||||
this.scrollIntoField(step[0])
|
||||
}
|
||||
|
||||
this.$refs.form.querySelector('input[type="date"], input[type="text"], select')?.focus()
|
||||
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus()
|
||||
|
||||
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
|
||||
this.$refs.form.querySelector('input[type="file"]')?.click()
|
||||
@@ -718,7 +813,7 @@ export default {
|
||||
})
|
||||
},
|
||||
saveStep (formData) {
|
||||
const currentFieldUuid = this.currentField.uuid
|
||||
const currentFieldUuids = this.currentStepFields.map((f) => f.uuid)
|
||||
|
||||
if (this.isCompleted) {
|
||||
return Promise.resolve({})
|
||||
@@ -728,7 +823,9 @@ export default {
|
||||
body: formData || new FormData(this.$refs.form)
|
||||
}).then((response) => {
|
||||
if (response.status === 200) {
|
||||
this.submittedValues[currentFieldUuid] = this.values[currentFieldUuid]
|
||||
currentFieldUuids.forEach((fieldUuid) => {
|
||||
this.submittedValues[fieldUuid] = this.values[fieldUuid]
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -750,7 +847,13 @@ export default {
|
||||
|
||||
stepPromise().then(async () => {
|
||||
const emptyRequiredField = this.stepFields.find((fields, index) => {
|
||||
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && (this.submittedValues[fields[0].uuid] === true ? false : !this.submittedValues[fields[0].uuid]?.length)
|
||||
if (index >= this.currentStep) {
|
||||
return false
|
||||
}
|
||||
|
||||
return fields.some((f) => {
|
||||
return f.required && (f.type === 'phone' || !this.allowToSkip) && isEmpty(this.submittedValues[f.uuid])
|
||||
})
|
||||
})
|
||||
|
||||
const formData = new FormData(this.$refs.form)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<template
|
||||
v-for="(field, fieldIndex) in fields"
|
||||
:key="field.uuid"
|
||||
>
|
||||
<template
|
||||
v-for="(area, areaIndex) in field.areas"
|
||||
:key="areaIndex"
|
||||
>
|
||||
<Teleport
|
||||
v-if="findPageElementForArea(area)"
|
||||
:to="findPageElementForArea(area)"
|
||||
>
|
||||
<FieldArea
|
||||
v-if="isMathLoaded"
|
||||
:model-value="calculateFormula(field)"
|
||||
:field="field"
|
||||
:area="area"
|
||||
:submittable="false"
|
||||
:field-index="fieldIndex"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FieldArea from './area'
|
||||
|
||||
export default {
|
||||
name: 'FormulaFieldAreas',
|
||||
components: {
|
||||
FieldArea
|
||||
},
|
||||
props: {
|
||||
fields: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isMathLoaded: false
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
const {
|
||||
create,
|
||||
evaluateDependencies,
|
||||
addDependencies,
|
||||
subtractDependencies,
|
||||
divideDependencies,
|
||||
multiplyDependencies,
|
||||
powDependencies,
|
||||
roundDependencies,
|
||||
absDependencies,
|
||||
sinDependencies,
|
||||
tanDependencies,
|
||||
cosDependencies
|
||||
} = await import('mathjs')
|
||||
|
||||
this.math = create({
|
||||
evaluateDependencies,
|
||||
addDependencies,
|
||||
subtractDependencies,
|
||||
divideDependencies,
|
||||
multiplyDependencies,
|
||||
powDependencies,
|
||||
roundDependencies,
|
||||
absDependencies,
|
||||
sinDependencies,
|
||||
tanDependencies,
|
||||
cosDependencies
|
||||
})
|
||||
|
||||
this.isMathLoaded = true
|
||||
},
|
||||
methods: {
|
||||
findPageElementForArea (area) {
|
||||
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${area.attachment_uuid}-${area.page}`)
|
||||
},
|
||||
calculateFormula (field) {
|
||||
const transformedFormula = field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
return this.values[uuid] || 0.0
|
||||
})
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,4 +1,21 @@
|
||||
const en = {
|
||||
text: 'Text',
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
date: 'Date',
|
||||
number: 'Number',
|
||||
image: 'Image',
|
||||
take_photo: 'Take photo',
|
||||
file: 'File',
|
||||
select: 'Select',
|
||||
checkbox: 'Checkbox',
|
||||
multiple: 'Multiple',
|
||||
radio: 'Radio',
|
||||
cells: 'Cells',
|
||||
stamp: 'Stamp',
|
||||
minimize: 'Minimize',
|
||||
payment: 'Payment',
|
||||
phone: 'Phone',
|
||||
submit_form: 'Submit Form',
|
||||
type_here_: 'Type here...',
|
||||
optional: 'optional',
|
||||
@@ -13,8 +30,6 @@ const en = {
|
||||
or_drag_and_drop_files: 'or drag and drop files',
|
||||
send_copy_via_email: 'Send copy via email',
|
||||
download: 'Download',
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
clear: 'Clear',
|
||||
redraw: 'Redraw',
|
||||
draw_initials: 'Draw initials',
|
||||
@@ -40,13 +55,32 @@ const en = {
|
||||
draw: 'Draw',
|
||||
type: 'Type',
|
||||
type_text: 'Type text',
|
||||
date: 'Date',
|
||||
email_has_been_sent: 'Email has been sent',
|
||||
processing: 'Processing',
|
||||
pay_with_strip: 'Pay with Stripe'
|
||||
pay_with_strip: 'Pay with Stripe',
|
||||
reupload: 'Reupload',
|
||||
upload: 'Upload',
|
||||
files: 'Files'
|
||||
}
|
||||
|
||||
const es = {
|
||||
minimize: 'Minimizar',
|
||||
text: 'Texto',
|
||||
signature: 'Firma',
|
||||
initials: 'Iniciales',
|
||||
date: 'Fecha',
|
||||
number: 'Número',
|
||||
image: 'Imagen',
|
||||
file: 'Archivo',
|
||||
select: 'Seleccionar',
|
||||
checkbox: 'Casilla',
|
||||
multiple: 'Múltiple',
|
||||
radio: 'Radio',
|
||||
cells: 'Celdas',
|
||||
stamp: 'Sello',
|
||||
payment: 'Pago',
|
||||
phone: 'Teléfono',
|
||||
take_photo: 'Tomar foto',
|
||||
submit_form: 'Enviar Formulario',
|
||||
type_here_: 'Escribe aquí...',
|
||||
optional: 'opcional',
|
||||
@@ -60,8 +94,6 @@ const es = {
|
||||
or_drag_and_drop_files: 'o arrastra y suelta archivos',
|
||||
send_copy_via_email: 'Enviar copia por correo electrónico',
|
||||
download: 'Descargar',
|
||||
signature: 'Firma',
|
||||
initials: 'Iniciales',
|
||||
clear: 'Borrar',
|
||||
redraw: 'Redibujar',
|
||||
draw_initials: 'Dibujar iniciales',
|
||||
@@ -81,7 +113,6 @@ const es = {
|
||||
verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS',
|
||||
please_fill_all_required_fields: 'Por favor, complete todos los campos obligatorios',
|
||||
set_today: 'Establecer Hoy',
|
||||
date: 'Fecha',
|
||||
toggle_multiline_text: 'Alternar Texto Multilínea',
|
||||
draw_signature: 'Dibujar firma',
|
||||
type_initial: 'Escribir iniciales',
|
||||
@@ -90,15 +121,35 @@ const es = {
|
||||
type_text: 'Escribir texto',
|
||||
email_has_been_sent: 'El correo electrónico ha sido enviado',
|
||||
processing: 'Procesando',
|
||||
pay_with_strip: 'Pagar con Stripe'
|
||||
pay_with_strip: 'Pagar con Stripe',
|
||||
reupload: 'Volver a subir',
|
||||
upload: 'Subir',
|
||||
files: 'Archivos'
|
||||
}
|
||||
|
||||
const it = {
|
||||
minimize: 'Minimizza',
|
||||
text: 'Testo',
|
||||
signature: 'Firma',
|
||||
initials: 'Iniziali',
|
||||
date: 'Data',
|
||||
number: 'Numero',
|
||||
image: 'Immagine',
|
||||
file: 'File',
|
||||
select: 'Seleziona',
|
||||
checkbox: 'Casella di controllo',
|
||||
multiple: 'Multiplo',
|
||||
radio: 'Radio',
|
||||
cells: 'Celle',
|
||||
stamp: 'Timbro',
|
||||
payment: 'Pagamento',
|
||||
phone: 'Telefono',
|
||||
submit_form: 'Invia Modulo',
|
||||
type_here_: 'Digita qui...',
|
||||
optional: 'opzionale',
|
||||
appears_on: 'Compare su',
|
||||
page: 'Pagina',
|
||||
take_photo: 'Scattare una foto',
|
||||
select_your_option: 'Seleziona la tua opzione',
|
||||
complete_hightlighted_checkboxes_and_click: 'Completa le caselle evidenziate e fai clic',
|
||||
submit: 'invia',
|
||||
@@ -107,8 +158,6 @@ const it = {
|
||||
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
|
||||
send_copy_via_email: 'Invia copia via email',
|
||||
download: 'Scarica',
|
||||
signature: 'Firma',
|
||||
initials: 'Iniziali',
|
||||
clear: 'Cancella',
|
||||
redraw: 'Ridisegna',
|
||||
draw_initials: 'Disegna iniziali',
|
||||
@@ -128,7 +177,6 @@ const it = {
|
||||
verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS',
|
||||
please_fill_all_required_fields: 'Si prega di compilare tutti i campi obbligatori',
|
||||
set_today: 'Imposta Oggi',
|
||||
date: 'Data',
|
||||
draw_signature: 'Disegna firma',
|
||||
type_initial: 'Inserisci iniziali',
|
||||
draw: 'Disegna',
|
||||
@@ -137,15 +185,35 @@ const it = {
|
||||
toggle_multiline_text: 'Attiva Testo Multilinea',
|
||||
email_has_been_sent: "L'email è stata inviata",
|
||||
processing: 'Elaborazione',
|
||||
pay_with_strip: 'Paga con Stripe'
|
||||
pay_with_strip: 'Paga con Stripe',
|
||||
reupload: 'Ricarica',
|
||||
upload: 'Carica',
|
||||
files: 'File'
|
||||
}
|
||||
|
||||
const de = {
|
||||
minimize: 'Minimieren',
|
||||
text: 'Text',
|
||||
signature: 'Unterschrift',
|
||||
initials: 'Initialen',
|
||||
date: 'Datum',
|
||||
number: 'Nummer',
|
||||
image: 'Bild',
|
||||
file: 'Datei',
|
||||
select: 'Auswählen',
|
||||
checkbox: 'Checkbox',
|
||||
multiple: 'Mehrere',
|
||||
radio: 'Radio',
|
||||
cells: 'Zellen',
|
||||
stamp: 'Stempel',
|
||||
payment: 'Zahlung',
|
||||
phone: 'Telefon',
|
||||
submit_form: 'Formular absenden',
|
||||
type_here_: 'Hier eingeben...',
|
||||
optional: 'optional',
|
||||
appears_on: 'Erscheint auf',
|
||||
page: 'Seite',
|
||||
take_photo: 'Foto aufnehmen',
|
||||
select_your_option: 'Wähle deine Option',
|
||||
complete_hightlighted_checkboxes_and_click: 'Markierte Kontrollkästchen ausfüllen und klicken',
|
||||
submit: 'absenden',
|
||||
@@ -154,8 +222,6 @@ const de = {
|
||||
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
|
||||
send_copy_via_email: 'Kopie per E-Mail senden',
|
||||
download: 'Herunterladen',
|
||||
signature: 'Unterschrift',
|
||||
initials: 'Initialen',
|
||||
clear: 'Löschen',
|
||||
redraw: 'Neu zeichnen',
|
||||
draw_initials: 'Initialen zeichnen',
|
||||
@@ -175,7 +241,6 @@ const de = {
|
||||
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet',
|
||||
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus',
|
||||
set_today: 'Heute einstellen',
|
||||
date: 'Datum',
|
||||
draw_signature: 'Unterschrift zeichnen',
|
||||
type_initial: 'Initialen eingeben',
|
||||
draw: 'Zeichnen',
|
||||
@@ -184,15 +249,35 @@ const de = {
|
||||
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
|
||||
email_has_been_sent: 'Die E-Mail wurde gesendet',
|
||||
processing: 'Verarbeitung',
|
||||
pay_with_strip: 'Mit Stripe bezahlen'
|
||||
pay_with_strip: 'Mit Stripe bezahlen',
|
||||
reupload: 'Erneut hochladen',
|
||||
upload: 'Hochladen',
|
||||
files: 'Dateien'
|
||||
}
|
||||
|
||||
const fr = {
|
||||
minimize: 'Réduire',
|
||||
text: 'Texte',
|
||||
signature: 'Signature',
|
||||
initials: 'Initiales',
|
||||
date: 'Date',
|
||||
number: 'Numéro',
|
||||
image: 'Image',
|
||||
file: 'Fichier',
|
||||
select: 'Choisir',
|
||||
checkbox: 'Coche',
|
||||
multiple: 'Multiple',
|
||||
radio: 'Radio',
|
||||
cells: 'Cellules',
|
||||
stamp: 'Tampon',
|
||||
payment: 'Paiement',
|
||||
phone: 'Téléphone',
|
||||
submit_form: 'Envoyer le Formulaire',
|
||||
type_here_: 'Tapez ici...',
|
||||
optional: 'facultatif',
|
||||
appears_on: 'Apparaît sur',
|
||||
page: 'Page',
|
||||
take_photo: 'Prendre une photo',
|
||||
select_your_option: 'Sélectionnez votre option',
|
||||
complete_hightlighted_checkboxes_and_click: 'Complétez les cases à cocher en surbrillance et cliquez',
|
||||
submit: 'envoyer',
|
||||
@@ -201,8 +286,6 @@ const fr = {
|
||||
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
|
||||
send_copy_via_email: 'Envoyer une copie par e-mail',
|
||||
download: 'Télécharger',
|
||||
signature: 'Signature',
|
||||
initials: 'Initiales',
|
||||
clear: 'Effacer',
|
||||
redraw: 'Redessiner',
|
||||
draw_initials: 'Dessiner les initiales',
|
||||
@@ -222,7 +305,6 @@ const fr = {
|
||||
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS',
|
||||
please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires',
|
||||
set_today: "Définir Aujourd'hui",
|
||||
date: 'Date',
|
||||
draw_signature: 'Dessiner une signature',
|
||||
type_initial: 'Saisir les initiales',
|
||||
draw: 'Dessiner',
|
||||
@@ -231,10 +313,29 @@ const fr = {
|
||||
toggle_multiline_text: 'Basculer le Texte Multiligne',
|
||||
email_has_been_sent: "L'email a été envoyé",
|
||||
processing: 'Traitement',
|
||||
pay_with_strip: 'Paiement avec Stripe'
|
||||
pay_with_strip: 'Paiement avec Stripe',
|
||||
reupload: 'Recharger',
|
||||
upload: 'Télécharger',
|
||||
files: 'Fichiers'
|
||||
}
|
||||
|
||||
const pl = {
|
||||
minimize: 'Zminimalizuj',
|
||||
text: 'Tekst',
|
||||
signature: 'Podpis',
|
||||
initials: 'Inicjały',
|
||||
date: 'Data',
|
||||
number: 'Numer',
|
||||
image: 'Obraz',
|
||||
file: 'Plik',
|
||||
select: 'Wybierz',
|
||||
checkbox: 'Pole wyboru',
|
||||
multiple: 'Wielokrotne',
|
||||
radio: 'Radio',
|
||||
cells: 'Komórki',
|
||||
stamp: 'Pieczątka',
|
||||
payment: 'Płatność',
|
||||
phone: 'Telefon',
|
||||
submit_form: 'Wyślij Formularz',
|
||||
type_here_: 'Wpisz tutaj...',
|
||||
optional: 'opcjonalny',
|
||||
@@ -244,12 +345,11 @@ const pl = {
|
||||
complete_hightlighted_checkboxes_and_click: 'Wypełnij zaznaczone pola wyboru i kliknij',
|
||||
submit: 'wyślij',
|
||||
next: 'dalej',
|
||||
take_photo: 'Zrób zdjęcie',
|
||||
click_to_upload: 'Kliknij, aby przesłać',
|
||||
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
|
||||
send_copy_via_email: 'Wyślij kopię drogą mailową',
|
||||
download: 'Pobierz',
|
||||
signature: 'Podpis',
|
||||
initials: 'Inicjały',
|
||||
clear: 'Wyczyść',
|
||||
redraw: 'Przerysuj',
|
||||
draw_initials: 'Narysuj inicjały',
|
||||
@@ -269,7 +369,6 @@ const pl = {
|
||||
verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany',
|
||||
please_fill_all_required_fields: 'Proszę wypełnić wszystkie wymagane pola',
|
||||
set_today: 'Ustaw Dziś',
|
||||
date: 'Data',
|
||||
draw_signature: 'Rysuj podpis',
|
||||
type_initial: 'Wprowadź inicjały',
|
||||
draw: 'Rysuj',
|
||||
@@ -278,15 +377,35 @@ const pl = {
|
||||
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
|
||||
email_has_been_sent: 'E-mail został wysłany',
|
||||
processing: 'Przetwarzanie',
|
||||
pay_with_strip: 'Płatność za pomocą Stripe'
|
||||
pay_with_strip: 'Płatność za pomocą Stripe',
|
||||
reupload: 'Ponowne przesłanie',
|
||||
upload: 'Przesyłanie',
|
||||
files: 'Pliki'
|
||||
}
|
||||
|
||||
const uk = {
|
||||
minimize: 'Зменшити',
|
||||
text: 'Текст',
|
||||
signature: 'Підпис',
|
||||
initials: 'Ініціали',
|
||||
date: 'Дата',
|
||||
number: 'Число',
|
||||
image: 'Зображення',
|
||||
file: 'Файл',
|
||||
select: 'Вибрати',
|
||||
checkbox: 'Прапорець',
|
||||
multiple: 'Множинний',
|
||||
radio: 'Радіо',
|
||||
cells: 'Клітинки',
|
||||
stamp: 'Штамп',
|
||||
payment: 'Платіж',
|
||||
phone: 'Телефон',
|
||||
submit_form: 'Надіслати Форму',
|
||||
type_here_: 'Введіть тут',
|
||||
optional: 'необов’язково',
|
||||
appears_on: "З'являється на",
|
||||
page: 'Сторінка',
|
||||
take_photo: 'Зробити фото',
|
||||
select_your_option: 'Виберіть варіант',
|
||||
complete_hightlighted_checkboxes_and_click: 'Заповніть позначені прапорці та натисніть',
|
||||
submit: 'надіслати',
|
||||
@@ -295,8 +414,6 @@ const uk = {
|
||||
or_drag_and_drop_files: 'або перетягніть файли сюди',
|
||||
send_copy_via_email: 'Надіслати копію електронною поштою',
|
||||
download: 'Завантажити',
|
||||
signature: 'Підпис',
|
||||
initials: 'Ініціали',
|
||||
clear: 'Очистити',
|
||||
redraw: 'Перемалювати',
|
||||
draw_initials: 'Намалювати ініціали',
|
||||
@@ -316,7 +433,6 @@ const uk = {
|
||||
verification_code_has_been_resent: 'Код підтвердження був повторно надісланий',
|
||||
please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля",
|
||||
set_today: 'Задати Сьогодні',
|
||||
date: 'Дата',
|
||||
draw_signature: 'Намалюйте підпис',
|
||||
type_initial: 'Введіть ініціали',
|
||||
draw: 'Підпис',
|
||||
@@ -325,16 +441,36 @@ const uk = {
|
||||
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
|
||||
email_has_been_sent: 'Електронний лист був відправлений',
|
||||
processing: 'Обробка',
|
||||
pay_with_strip: 'Сплатити за допомогою Stripe'
|
||||
pay_with_strip: 'Сплатити за допомогою Stripe',
|
||||
reupload: 'Перезавантажити',
|
||||
upload: 'Завантажити',
|
||||
files: 'Файли'
|
||||
}
|
||||
|
||||
const cs = {
|
||||
minimize: 'Minimalizovat',
|
||||
text: 'Text',
|
||||
signature: 'Podpis',
|
||||
initials: 'Iniciály',
|
||||
date: 'Datum',
|
||||
number: 'Číslo',
|
||||
image: 'Obrázek',
|
||||
file: 'Soubor',
|
||||
select: 'Vybrat',
|
||||
checkbox: 'Zaškrtávací políčko',
|
||||
multiple: 'Více možností',
|
||||
radio: 'Rádio',
|
||||
cells: 'Buňky',
|
||||
stamp: 'Razítko',
|
||||
payment: 'Platba',
|
||||
phone: 'Telefon',
|
||||
submit_form: 'Odeslat formulář',
|
||||
type_here_: 'Zadejte zde',
|
||||
optional: 'volitelné',
|
||||
appears_on: 'Zobrazuje se na',
|
||||
page: 'Stránka',
|
||||
select_your_option: 'Vyberte svou volbu',
|
||||
take_photo: 'Fotit',
|
||||
complete_hightlighted_checkboxes_and_click: 'Označte zvýrazněné zaškrtávací políčka a klikněte na',
|
||||
submit: 'odeslat',
|
||||
next: 'další',
|
||||
@@ -342,8 +478,6 @@ const cs = {
|
||||
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
|
||||
send_copy_via_email: 'Odeslat kopii e-mailem',
|
||||
download: 'Stáhnout',
|
||||
signature: 'Podpis',
|
||||
initials: 'Iniciály',
|
||||
clear: 'Smazat',
|
||||
redraw: 'Překreslit',
|
||||
draw_initials: 'Nakreslit iniciály',
|
||||
@@ -363,7 +497,6 @@ const cs = {
|
||||
verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán',
|
||||
please_fill_all_required_fields: 'Prosím vyplňte všechny povinné položky',
|
||||
set_today: 'Nastavit Dnes',
|
||||
date: 'Datum',
|
||||
draw_signature: 'Nakreslit podpis',
|
||||
type_initial: 'Zadat iniciály',
|
||||
draw: 'Kreslit',
|
||||
@@ -372,15 +505,35 @@ const cs = {
|
||||
toggle_multiline_text: 'Přepnout Víceřádkový Text',
|
||||
email_has_been_sent: 'E-mail byl odeslán',
|
||||
processing: 'Zpracování',
|
||||
pay_with_strip: 'Zaplacení přes Stripe'
|
||||
pay_with_strip: 'Zaplacení přes Stripe',
|
||||
reupload: 'Znovu nahrát',
|
||||
upload: 'Nahrát',
|
||||
files: 'Soubory'
|
||||
}
|
||||
|
||||
const pt = {
|
||||
minimize: 'Minimizar',
|
||||
text: 'Texto',
|
||||
signature: 'Assinatura',
|
||||
initials: 'Iniciais',
|
||||
date: 'Data',
|
||||
number: 'Número',
|
||||
image: 'Imagem',
|
||||
file: 'Arquivo',
|
||||
select: 'Selecionar',
|
||||
checkbox: 'Caixa',
|
||||
multiple: 'Múltiplo',
|
||||
radio: 'Rádio',
|
||||
cells: 'Células',
|
||||
stamp: 'Carimbo',
|
||||
payment: 'Pagamento',
|
||||
phone: 'Telefone',
|
||||
submit_form: 'Enviar Formulário',
|
||||
type_here_: 'Digite aqui',
|
||||
optional: 'opcional',
|
||||
appears_on: 'Aparece em',
|
||||
page: 'Página',
|
||||
take_photo: 'Tirar foto',
|
||||
select_your_option: 'Selecione sua opção',
|
||||
complete_hightlighted_checkboxes_and_click: 'Complete as caixas de seleção destacadas e clique',
|
||||
submit: 'enviar',
|
||||
@@ -389,8 +542,6 @@ const pt = {
|
||||
or_drag_and_drop_files: 'ou arraste e solte arquivos',
|
||||
send_copy_via_email: 'Enviar cópia por e-mail',
|
||||
download: 'Baixar',
|
||||
signature: 'Assinatura',
|
||||
initials: 'Iniciais',
|
||||
clear: 'Limpar',
|
||||
redraw: 'Redesenhar',
|
||||
draw_initials: 'Desenhar iniciais',
|
||||
@@ -410,7 +561,6 @@ const pt = {
|
||||
verification_code_has_been_resent: 'O código de verificação foi reenviado via SMS',
|
||||
please_fill_all_required_fields: 'Por favor, preencha todos os campos obrigatórios',
|
||||
set_today: 'Definir Hoje',
|
||||
date: 'Data',
|
||||
draw_signature: 'Desenhar assinatura',
|
||||
type_initial: 'Inserir iniciais',
|
||||
draw: 'Desenhar',
|
||||
@@ -419,16 +569,36 @@ const pt = {
|
||||
toggle_multiline_text: 'Alternar Texto Multilinha',
|
||||
email_has_been_sent: 'Email enviado',
|
||||
processing: 'Processamento',
|
||||
pay_with_strip: 'Pagar com Stripe'
|
||||
pay_with_strip: 'Pagar com Stripe',
|
||||
reupload: 'Reenviar',
|
||||
upload: 'Carregar',
|
||||
files: 'Arquivos'
|
||||
}
|
||||
|
||||
const he = {
|
||||
minimize: 'לקטן',
|
||||
text: 'טקסט',
|
||||
signature: 'חתימה',
|
||||
initials: 'ראשי תיבות',
|
||||
date: 'תאריך',
|
||||
number: 'מספר',
|
||||
image: 'תמונה',
|
||||
file: 'קובץ',
|
||||
select: 'בחר',
|
||||
checkbox: 'תיק בחירה',
|
||||
multiple: 'רב ערכים',
|
||||
radio: 'רדיו',
|
||||
cells: 'תאים',
|
||||
stamp: 'חותמת',
|
||||
payment: 'תשלום',
|
||||
phone: 'טלפון',
|
||||
submit_form: 'שלח טופס',
|
||||
type_here_: 'הקלד כאן',
|
||||
optional: 'אופציונלי',
|
||||
option: 'אפשרות',
|
||||
appears_on: 'מופיע ב',
|
||||
page: 'עמוד',
|
||||
take_photo: 'צלם תמונה',
|
||||
select_your_option: 'בחר את האפשרות שלך',
|
||||
complete_hightlighted_checkboxes_and_click: 'השלם את תיבות הסימון המודגשות ולחץ',
|
||||
submit: 'שליחה',
|
||||
@@ -437,8 +607,6 @@ const he = {
|
||||
or_drag_and_drop_files: 'או גרור ושחרר קבצים',
|
||||
send_copy_via_email: 'שלח עותק באמצעות אימייל',
|
||||
download: 'הורדה',
|
||||
signature: 'חתימה',
|
||||
initials: 'ציוני ראשי תיבות',
|
||||
clear: 'נקה',
|
||||
redraw: 'צייר מחדש',
|
||||
draw_initials: 'צייר ציוני ראשי תיבות',
|
||||
@@ -464,17 +632,144 @@ const he = {
|
||||
draw: 'צייר',
|
||||
type: 'הקלד',
|
||||
type_text: 'הקלד טקסט',
|
||||
date: 'תאריך',
|
||||
email_has_been_sent: 'האימייל נשלח',
|
||||
processing: 'מעבד',
|
||||
pay_with_strip: 'שלם עם סטרייפ'
|
||||
pay_with_strip: 'שלם עם סטרייפ',
|
||||
reupload: 'העלה שוב',
|
||||
upload: 'העלאה',
|
||||
files: 'קבצים'
|
||||
}
|
||||
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he }
|
||||
const nl = {
|
||||
minimize: 'Minimaliseren',
|
||||
text: 'Tekst',
|
||||
signature: 'Handtekening',
|
||||
initials: 'Initialen',
|
||||
date: 'Datum',
|
||||
number: 'Nummer',
|
||||
image: 'Afbeelding',
|
||||
take_photo: 'Maak een foto',
|
||||
file: 'Bestand',
|
||||
select: 'Selecteer',
|
||||
checkbox: 'Aankruisvakje',
|
||||
multiple: 'Meerdere',
|
||||
radio: 'Radio',
|
||||
cells: 'Cellen',
|
||||
stamp: 'Stempel',
|
||||
payment: 'Betaling',
|
||||
phone: 'Telefoon',
|
||||
submit_form: 'Formulier verzenden',
|
||||
type_here_: 'Typ hier...',
|
||||
optional: 'Optioneel',
|
||||
option: 'Optie',
|
||||
appears_on: 'Verschijnt op',
|
||||
page: 'Pagina',
|
||||
select_your_option: 'Selecteer uw optie',
|
||||
complete_hightlighted_checkboxes_and_click: 'Voltooi de gemarkeerde selectievakjes en klik',
|
||||
submit: 'Voltooien',
|
||||
next: 'Volgende',
|
||||
click_to_upload: 'Klik om te uploaden',
|
||||
or_drag_and_drop_files: 'of sleep en plaats bestanden',
|
||||
send_copy_via_email: 'Stuur kopie per e-mail',
|
||||
download: 'Downloaden',
|
||||
clear: 'Wissen',
|
||||
redraw: 'Opnieuw tekenen',
|
||||
draw_initials: 'Initialen tekenen',
|
||||
type_signature_here: 'Typ hier uw handtekening',
|
||||
type_initial_here: 'Typ hier uw initialen',
|
||||
form_has_been_completed: 'Formulier is voltooid!',
|
||||
create_a_free_account: 'Maak een gratis account aan',
|
||||
signed_with: 'Ondertekend met',
|
||||
please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan',
|
||||
open_source_documents_software: 'Open source documenten software',
|
||||
verified_phone_number: 'Geverifieerd telefoonnummer',
|
||||
use_international_format: 'Gebruik internationaal formaat: +1xxx',
|
||||
six_digits_code: '6-cijferige code',
|
||||
change_phone_number: 'Wijzig telefoonnummer',
|
||||
sending: 'Voltooien...',
|
||||
resend_code: 'Code opnieuw verzenden',
|
||||
verification_code_has_been_resent: 'Verificatiecode is opnieuw verzonden via SMS',
|
||||
please_fill_all_required_fields: 'Vul alle verplichte velden in',
|
||||
set_today: 'Vandaag',
|
||||
toggle_multiline_text: 'Schakel meerregelige tekst in/uit',
|
||||
draw_signature: 'Handtekening tekenen',
|
||||
type_initial: 'Typ initialen',
|
||||
draw: 'Tekenen',
|
||||
type: 'Typen',
|
||||
type_text: 'Typ tekst',
|
||||
email_has_been_sent: 'E-mail is verzonden',
|
||||
processing: 'Verwerken',
|
||||
pay_with_strip: 'Betalen met Stripe',
|
||||
reupload: 'Opnieuw uploaden',
|
||||
upload: 'Uploaden',
|
||||
files: 'Bestanden'
|
||||
}
|
||||
|
||||
const browserLanguage = (navigator.language || navigator.userLanguage || 'en').split('-')[0]
|
||||
const ar = {
|
||||
text: 'نص',
|
||||
signature: 'توقيع',
|
||||
initials: 'الاختصارات',
|
||||
date: 'تاريخ',
|
||||
number: 'رقم',
|
||||
image: 'صورة',
|
||||
take_photo: 'التقاط صورة',
|
||||
file: 'ملف',
|
||||
select: 'اختيار',
|
||||
checkbox: 'خانة اختيار',
|
||||
multiple: 'متعدد',
|
||||
radio: 'راديو',
|
||||
cells: 'خلايا',
|
||||
stamp: 'ختم',
|
||||
minimize: 'تصغير',
|
||||
payment: 'الدفع',
|
||||
phone: 'هاتف',
|
||||
submit_form: 'إرسال النموذج',
|
||||
type_here_: 'اكتب هنا',
|
||||
optional: 'اختياري',
|
||||
option: 'خيار',
|
||||
appears_on: 'يظهر على',
|
||||
page: 'صفحة',
|
||||
select_your_option: 'اختر خيارك',
|
||||
complete_hightlighted_checkboxes_and_click: 'أكمل الخانات المميزة وانقر',
|
||||
submit: 'إرسال',
|
||||
next: 'التالي',
|
||||
click_to_upload: 'انقر للتحميل',
|
||||
or_drag_and_drop_files: 'أو اسحب وأسقط الملفات',
|
||||
send_copy_via_email: 'إرسال نسخة عبر البريد الإلكتروني',
|
||||
download: 'تحميل',
|
||||
clear: 'مسح',
|
||||
redraw: 'إعادة الرسم',
|
||||
draw_initials: 'ارسم الاختصارات',
|
||||
type_signature_here: 'اكتب التوقيع هنا',
|
||||
type_initial_here: 'اكتب الاختصارات هنا',
|
||||
form_has_been_completed: 'تم إكمال النموذج!',
|
||||
create_a_free_account: 'إنشاء حساب مجاني',
|
||||
signed_with: 'تم التوقيع بواسطة',
|
||||
please_check_the_box_to_continue: 'الرجاء التحقق من الخانة للمتابعة',
|
||||
open_source_documents_software: 'برنامج وثائق مفتوح المصدر',
|
||||
verified_phone_number: 'تحقق من رقم الهاتف',
|
||||
use_international_format: 'استخدم الشكل الدولي: +1xxx',
|
||||
six_digits_code: 'رمز مكون من 6 أرقام',
|
||||
change_phone_number: 'تغيير رقم الهاتف',
|
||||
sending: 'جارٍ الإرسال',
|
||||
resend_code: 'إعادة إرسال الرمز',
|
||||
verification_code_has_been_resent: 'تم إعادة إرسال رمز التحقق عبر الرسائل القصيرة',
|
||||
please_fill_all_required_fields: 'الرجاء ملء جميع الحقول المطلوبة',
|
||||
set_today: 'تعيين اليوم',
|
||||
toggle_multiline_text: 'تبديل النصوص متعددة الأسطر',
|
||||
draw_signature: 'ارسم التوقيع',
|
||||
type_initial: 'اكتب الاختصارات',
|
||||
draw: 'ارسم',
|
||||
type: 'اكتب',
|
||||
type_text: 'اكتب نصًا',
|
||||
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
|
||||
processing: 'جارٍ المعالجة',
|
||||
pay_with_strip: 'الدفع بواسطة Stripe',
|
||||
reupload: 'إعادة التحميل',
|
||||
upload: 'تحميل',
|
||||
files: 'الملفات'
|
||||
}
|
||||
|
||||
const t = (key) => i18n[browserLanguage]?.[key] || i18n.en[key] || key
|
||||
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar }
|
||||
|
||||
export default i18n
|
||||
export { t }
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<label
|
||||
class="label text-2xl"
|
||||
>{{ field.name || 'Image' }}</label>
|
||||
>{{ showFieldNames && field.name ? field.name : t('image') }}</label>
|
||||
<button
|
||||
class="btn btn-outline btn-sm"
|
||||
@click.prevent="remove"
|
||||
>
|
||||
<IconReload :width="16" />
|
||||
Reupload
|
||||
{{ t('reupload') }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
@@ -27,7 +27,7 @@
|
||||
<div>
|
||||
<FileDropzone
|
||||
v-if="!modelValue"
|
||||
:message="`Upload ${field.name || 'Image'}${field.required ? '' : ' (optional)'}`"
|
||||
:message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:submitter-slug="submitterSlug"
|
||||
:accept="'image/*'"
|
||||
:is-direct-upload="isDirectUpload"
|
||||
@@ -46,6 +46,7 @@ export default {
|
||||
FileDropzone,
|
||||
IconReload
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -56,6 +57,11 @@ export default {
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
submitterSlug: {
|
||||
type: String,
|
||||
required: true
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<label
|
||||
class="label text-2xl"
|
||||
>{{ field.name || t('initials') }}</label>
|
||||
>{{ showFieldNames && field.name ? field.name : t('initials') }}</label>
|
||||
<div class="space-x-2 flex">
|
||||
<span
|
||||
v-if="isDrawInitials"
|
||||
class="tooltip"
|
||||
:data-tip="t('type_initials')"
|
||||
:data-tip="t('type_initial')"
|
||||
>
|
||||
<a
|
||||
id="type_text_button"
|
||||
@@ -58,7 +58,7 @@
|
||||
{{ t('clear') }}
|
||||
</a>
|
||||
<a
|
||||
title="Minimize"
|
||||
:title="t('minimize')"
|
||||
href="#"
|
||||
class="py-1.5 inline md:hidden"
|
||||
@click.prevent="$emit('minimize')"
|
||||
@@ -84,7 +84,7 @@
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
class="bg-white border border-base-300 rounded-2xl"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full"
|
||||
/>
|
||||
<input
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
@@ -106,6 +106,8 @@ import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 }
|
||||
import SignaturePad from 'signature_pad'
|
||||
import AppearsOn from './appears_on'
|
||||
|
||||
const scale = 3
|
||||
|
||||
export default {
|
||||
name: 'InitialsStep',
|
||||
components: {
|
||||
@@ -125,6 +127,11 @@ export default {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
@@ -166,8 +173,10 @@ export default {
|
||||
async mounted () {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.canvas) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 5
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 3) * scale
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
}
|
||||
|
||||
this.$refs.textInput?.focus()
|
||||
@@ -185,8 +194,26 @@ export default {
|
||||
|
||||
this.$emit('start')
|
||||
})
|
||||
|
||||
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = (this.$refs.canvas.parentNode.clientWidth / 3) * scale
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
|
||||
this.intersectionObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.intersectionObserver.observe(this.$refs.canvas)
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.intersectionObserver?.disconnect()
|
||||
},
|
||||
methods: {
|
||||
remove () {
|
||||
this.$emit('update:model-value', '')
|
||||
@@ -204,7 +231,7 @@ export default {
|
||||
}
|
||||
},
|
||||
updateWrittenInitials (e) {
|
||||
this.isInitialsStarted = true
|
||||
this.isInitialsStarted = !!e.target.value
|
||||
|
||||
const canvas = this.$refs.canvas
|
||||
const context = canvas.getContext('2d')
|
||||
@@ -217,8 +244,8 @@ export default {
|
||||
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
||||
context.textAlign = 'center'
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
|
||||
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
|
||||
context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
|
||||
},
|
||||
toggleTextInput () {
|
||||
this.remove()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="field.name"
|
||||
v-if="showFieldNames && field.name"
|
||||
:for="field.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ field.name }}</label>
|
||||
<div class="flex w-full">
|
||||
<div class="flex w-full max-h-44 overflow-y-auto">
|
||||
<input
|
||||
v-if="modelValue.length === 0"
|
||||
type="text"
|
||||
@@ -13,7 +13,18 @@
|
||||
:value="''"
|
||||
class="hidden"
|
||||
>
|
||||
<div class="space-y-3.5 mx-auto">
|
||||
<div
|
||||
v-if="!showOptions"
|
||||
class="text-xl px-1"
|
||||
>
|
||||
<span @click="scrollIntoField(field)">
|
||||
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ isLastStep ? t('submit') : t('next') }}</span>.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="space-y-3.5 mx-auto"
|
||||
:class="{ hidden: !showOptions }"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in field.options"
|
||||
:key="option.uuid"
|
||||
@@ -44,12 +55,22 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'MultiSelectStep',
|
||||
inject: ['t'],
|
||||
inject: ['t', 'scrollIntoField'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isLastStep: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: false
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
modelValue: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -62,6 +83,11 @@ export default {
|
||||
inputRefs: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showOptions () {
|
||||
return this.showFieldNames && (this.field.options.some((e) => e.value) || this.field.options.length < 5)
|
||||
}
|
||||
},
|
||||
beforeUpdate () {
|
||||
this.inputRefs = []
|
||||
},
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="showFieldNames && field.name"
|
||||
:for="field.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl"
|
||||
:class="{ 'mb-2': !field.description }"
|
||||
><template v-if="field.title"><span v-html="field.title" /></template>
|
||||
<template v-else>{{ field.name }}</template>
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</label>
|
||||
<div
|
||||
v-else
|
||||
class="py-1"
|
||||
/>
|
||||
<div
|
||||
v-if="field.description"
|
||||
class="mb-3 px-1 text-lg"
|
||||
v-html="field.description"
|
||||
/>
|
||||
<AppearsOn :field="field" />
|
||||
<div class="items-center flex">
|
||||
<input
|
||||
type="hidden"
|
||||
name="cast_number"
|
||||
value="true"
|
||||
>
|
||||
<input
|
||||
:id="field.uuid"
|
||||
v-model="number"
|
||||
type="number"
|
||||
class="base-input !text-2xl w-full"
|
||||
step="any"
|
||||
:required="field.required"
|
||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@focus="$emit('focus')"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppearsOn from './appears_on'
|
||||
|
||||
export default {
|
||||
name: 'TextStep',
|
||||
components: {
|
||||
AppearsOn
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:model-value', 'focus'],
|
||||
computed: {
|
||||
number: {
|
||||
set (value) {
|
||||
this.$emit('update:model-value', value)
|
||||
},
|
||||
get () {
|
||||
return this.modelValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<label
|
||||
:for="isCodeSent ? 'one_time_code' : field.uuid"
|
||||
class="label text-2xl mb-2"
|
||||
>{{ field.name || t('verified_phone_number') }}
|
||||
>{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
|
||||
<template v-if="!field.required">({{ t('optional') }})</template>
|
||||
</label>
|
||||
<div>
|
||||
@@ -93,6 +93,11 @@ export default {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -140,7 +145,11 @@ export default {
|
||||
})
|
||||
},
|
||||
async submit () {
|
||||
if (!this.isCodeSent) {
|
||||
if (!this.$refs.phone.value.toString().startsWith('+')) {
|
||||
alert(this.t('use_international_format'))
|
||||
|
||||
return Promise.reject(new Error('phone invalid'))
|
||||
} else if (!this.isCodeSent) {
|
||||
this.sendVerificationCode()
|
||||
|
||||
this.$emit('update:model-value', this.$refs.phone.value)
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<div class="flex justify-between items-center w-full mb-2">
|
||||
<label
|
||||
class="label text-2xl"
|
||||
>{{ field.name || t('signature') }}</label>
|
||||
>{{ showFieldNames && field.name ? field.name : t('signature') }}</label>
|
||||
<div class="space-x-2 flex">
|
||||
<span
|
||||
v-if="isTextSignature"
|
||||
v-if="isTextSignature && field.preferences?.format !== 'typed'"
|
||||
class="tooltip"
|
||||
:data-tip="t('draw_signature')"
|
||||
>
|
||||
@@ -23,7 +23,7 @@
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="withTypedSignature"
|
||||
v-else-if="withTypedSignature && field.preferences?.format !== 'typed' && field.preferences?.format !== 'drawn'"
|
||||
class="tooltip ml-2"
|
||||
:data-tip="t('type_text')"
|
||||
>
|
||||
@@ -40,14 +40,16 @@
|
||||
</a>
|
||||
</span>
|
||||
<span
|
||||
v-if="field.preferences?.format !== 'typed'"
|
||||
class="tooltip"
|
||||
data-tip="Take photo"
|
||||
:data-tip="t('take_photo')"
|
||||
>
|
||||
<label
|
||||
class="btn btn-outline btn-sm font-medium"
|
||||
>
|
||||
<IconCamera :width="16" />
|
||||
<input
|
||||
:key="uploadImageInputKey"
|
||||
type="file"
|
||||
hidden
|
||||
accept="image/*"
|
||||
@@ -78,7 +80,7 @@
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
title="Minimize"
|
||||
:title="t('minimize')"
|
||||
class="py-1.5 inline md:hidden"
|
||||
@click.prevent="$emit('minimize')"
|
||||
>
|
||||
@@ -104,14 +106,14 @@
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
ref="canvas"
|
||||
style="padding: 1px; 0"
|
||||
class="bg-white border border-base-300 rounded-2xl"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full"
|
||||
/>
|
||||
<input
|
||||
v-if="isTextSignature"
|
||||
id="signature_text_input"
|
||||
ref="textInput"
|
||||
class="base-input !text-2xl w-full mt-6"
|
||||
:required="field.required"
|
||||
:required="field.required && !isSignatureStarted"
|
||||
:placeholder="`${t('type_signature_here')}...`"
|
||||
type="text"
|
||||
@input="updateWrittenSignature"
|
||||
@@ -127,6 +129,8 @@ import AppearsOn from './appears_on'
|
||||
|
||||
let isFontLoaded = false
|
||||
|
||||
const scale = 3
|
||||
|
||||
export default {
|
||||
name: 'SignatureStep',
|
||||
components: {
|
||||
@@ -147,6 +151,11 @@ export default {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
isDirectUpload: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
@@ -178,7 +187,8 @@ export default {
|
||||
return {
|
||||
isSignatureStarted: !!this.previousValue,
|
||||
isUsePreviousValue: true,
|
||||
isTextSignature: false
|
||||
isTextSignature: this.field.preferences?.format === 'typed',
|
||||
uploadImageInputKey: Math.random().toString()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -193,8 +203,10 @@ export default {
|
||||
async mounted () {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.canvas) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -210,8 +222,26 @@ export default {
|
||||
|
||||
this.$emit('start')
|
||||
})
|
||||
|
||||
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth * scale
|
||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth * scale / 3
|
||||
|
||||
this.$refs.canvas.getContext('2d').scale(scale, scale)
|
||||
|
||||
this.intersectionObserver?.disconnect()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.intersectionObserver.observe(this.$refs.canvas)
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.intersectionObserver?.disconnect()
|
||||
},
|
||||
methods: {
|
||||
remove () {
|
||||
this.$emit('update:model-value', '')
|
||||
@@ -221,7 +251,7 @@ export default {
|
||||
},
|
||||
loadFont () {
|
||||
if (!isFontLoaded) {
|
||||
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript.otf) format("opentype")`)
|
||||
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript-Regular.otf) format("opentype")`)
|
||||
|
||||
font.load().then((loadedFont) => {
|
||||
document.fonts.add(loadedFont)
|
||||
@@ -242,7 +272,7 @@ export default {
|
||||
}
|
||||
},
|
||||
updateWrittenSignature (e) {
|
||||
this.isSignatureStarted = true
|
||||
this.isSignatureStarted = !!e.target.value
|
||||
|
||||
const canvas = this.$refs.canvas
|
||||
const context = canvas.getContext('2d')
|
||||
@@ -255,11 +285,12 @@ export default {
|
||||
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
|
||||
context.textAlign = 'center'
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
|
||||
context.clearRect(0, 0, canvas.width / scale, canvas.height / scale)
|
||||
context.fillText(e.target.value, canvas.width / 2 / scale, canvas.height / 2 / scale + 11)
|
||||
},
|
||||
toggleTextInput () {
|
||||
this.remove()
|
||||
this.clear()
|
||||
this.isTextSignature = !this.isTextSignature
|
||||
|
||||
if (this.isTextSignature) {
|
||||
@@ -291,14 +322,16 @@ export default {
|
||||
const context = canvas.getContext('2d')
|
||||
|
||||
const aspectRatio = img.width / img.height
|
||||
const canvasWidth = canvas.width / scale
|
||||
const canvasHeight = canvas.height / scale
|
||||
|
||||
let targetWidth = canvas.width
|
||||
let targetHeight = canvas.height
|
||||
let targetWidth = canvasWidth
|
||||
let targetHeight = canvasHeight
|
||||
|
||||
if (canvas.width / canvas.height > aspectRatio) {
|
||||
targetWidth = canvas.height * aspectRatio
|
||||
if (canvasWidth / canvasHeight > aspectRatio) {
|
||||
targetWidth = canvasHeight * aspectRatio
|
||||
} else {
|
||||
targetHeight = canvas.width / aspectRatio
|
||||
targetHeight = canvasWidth / aspectRatio
|
||||
}
|
||||
|
||||
if (targetHeight > targetWidth) {
|
||||
@@ -307,17 +340,21 @@ export default {
|
||||
targetHeight = targetHeight * scale
|
||||
}
|
||||
|
||||
const x = (canvas.width - targetWidth) / 2
|
||||
const y = (canvas.height - targetHeight) / 2
|
||||
const x = (canvasWidth - targetWidth) / 2
|
||||
const y = (canvasHeight - targetHeight) / 2
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height)
|
||||
context.drawImage(img, x, y, targetWidth, targetHeight)
|
||||
setTimeout(() => {
|
||||
context.clearRect(0, 0, canvasWidth, canvasHeight)
|
||||
context.drawImage(img, x, y, targetWidth, targetHeight)
|
||||
|
||||
this.$emit('start')
|
||||
this.$emit('start')
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
this.uploadImageInputKey = Math.random().toString()
|
||||
}
|
||||
},
|
||||
async submit () {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<label
|
||||
v-if="field.name"
|
||||
v-if="showFieldNames && field.name"
|
||||
:for="field.uuid"
|
||||
dir="auto"
|
||||
class="label text-2xl"
|
||||
@@ -26,7 +26,8 @@
|
||||
v-model="text"
|
||||
:maxlength="cellsMaxLegth"
|
||||
dir="auto"
|
||||
class="base-input !text-2xl w-full !pr-11 -mr-10"
|
||||
class="base-input !text-2xl w-full"
|
||||
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
|
||||
:required="field.required"
|
||||
:pattern="field.validation?.pattern"
|
||||
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
|
||||
@@ -50,7 +51,7 @@
|
||||
@focus="$emit('focus')"
|
||||
/>
|
||||
<div
|
||||
v-if="!isTextArea && field.type !== 'cells'"
|
||||
v-if="!isTextArea && field.type !== 'cells' && !field.validation?.pattern"
|
||||
class="tooltip"
|
||||
:data-tip="t('toggle_multiline_text')"
|
||||
>
|
||||
@@ -81,6 +82,11 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showFieldNames: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -117,12 +123,14 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.isTextArea = this.modelValue?.includes('\n')
|
||||
if (this.modelValue) {
|
||||
this.isTextArea = this.modelValue.toString().includes('\n')
|
||||
|
||||
if (this.isTextArea) {
|
||||
this.$nextTick(() => {
|
||||
this.resizeTextarea()
|
||||
})
|
||||
if (this.isTextArea) {
|
||||
this.$nextTick(() => {
|
||||
this.resizeTextarea()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
class="border-r"
|
||||
:compact="true"
|
||||
:editable="editable && !defaultField"
|
||||
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px]'"
|
||||
:allow-add-new="!defaultSubmitters.length"
|
||||
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px] mt-[1px]'"
|
||||
:submitters="template.submitters"
|
||||
@update:model-value="save"
|
||||
@click="selectedAreaRef.value = area"
|
||||
@@ -89,7 +90,7 @@
|
||||
<button
|
||||
v-else-if="editable"
|
||||
class="pr-1"
|
||||
title="Remove"
|
||||
:title="t('remove')"
|
||||
@click.prevent="$emit('remove')"
|
||||
>
|
||||
<IconX width="14" />
|
||||
@@ -97,21 +98,32 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center h-full w-full"
|
||||
dir="auto"
|
||||
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']"
|
||||
>
|
||||
<span
|
||||
v-if="field"
|
||||
class="flex justify-center items-center space-x-1 h-full"
|
||||
:class="{'w-full h-full': field.type == 'checkbox'}"
|
||||
>
|
||||
<div
|
||||
v-if="field?.default_value"
|
||||
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
|
||||
:class="{ 'w-full h-full': field.type == 'checkbox', 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
|
||||
>
|
||||
<div
|
||||
ref="textContainer"
|
||||
class="flex items-center px-0.5"
|
||||
:class="{'w-full h-full': field.type == 'checkbox'}"
|
||||
>
|
||||
<span class="whitespace-pre-wrap">{{ field.default_value }}</span>
|
||||
<IconCheck
|
||||
v-if="field.type == 'checkbox'"
|
||||
class="aspect-square mx-auto"
|
||||
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="whitespace-pre-wrap"
|
||||
>{{ field.default_value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<component
|
||||
@@ -140,13 +152,14 @@
|
||||
import FieldSubmitter from './field_submitter'
|
||||
import FieldType from './field_type'
|
||||
import Field from './field'
|
||||
import { IconX } from '@tabler/icons-vue'
|
||||
import { IconX, IconCheck } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
name: 'FieldArea',
|
||||
components: {
|
||||
FieldType,
|
||||
IconCheck,
|
||||
FieldSubmitter,
|
||||
IconX
|
||||
},
|
||||
@@ -166,6 +179,11 @@ export default {
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -188,9 +206,11 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
defaultName: Field.computed.defaultName,
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
defaultName () {
|
||||
return this.buildDefaultName(this.field, this.template.fields)
|
||||
},
|
||||
optionIndexText () {
|
||||
if (this.area.option_uuid && this.field.options) {
|
||||
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
|
||||
@@ -219,6 +239,16 @@ export default {
|
||||
},
|
||||
borderColors () {
|
||||
return [
|
||||
'border-red-500/80',
|
||||
'border-sky-500/80',
|
||||
'border-emerald-500/80',
|
||||
'border-yellow-300/80',
|
||||
'border-purple-600/80',
|
||||
'border-pink-500/80',
|
||||
'border-cyan-500/80',
|
||||
'border-orange-500/80',
|
||||
'border-lime-500/80',
|
||||
'border-indigo-500/80',
|
||||
'border-red-500/80',
|
||||
'border-sky-500/80',
|
||||
'border-emerald-500/80',
|
||||
@@ -233,6 +263,16 @@ export default {
|
||||
},
|
||||
bgColors () {
|
||||
return [
|
||||
'bg-red-100/80',
|
||||
'bg-sky-100/80',
|
||||
'bg-emerald-100/80',
|
||||
'bg-yellow-100/80',
|
||||
'bg-purple-100/80',
|
||||
'bg-pink-100/80',
|
||||
'bg-cyan-100/80',
|
||||
'bg-orange-100/80',
|
||||
'bg-lime-100/80',
|
||||
'bg-indigo-100/80',
|
||||
'bg-red-100/80',
|
||||
'bg-sky-100/80',
|
||||
'bg-emerald-100/80',
|
||||
@@ -274,6 +314,7 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName: Field.methods.buildDefaultName,
|
||||
onNameFocus (e) {
|
||||
this.selectedAreaRef.value = this.area
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="$slots.buttons || withTitle"
|
||||
id="title_container"
|
||||
class="flex justify-between py-1.5 items-center pr-4 top-0 z-10"
|
||||
:class="{ sticky: withStickySubmitters || isBreakpointLg }"
|
||||
:style="{ backgroundColor }"
|
||||
@@ -61,6 +62,7 @@
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
v-if="editable"
|
||||
class="base-button"
|
||||
:class="{ disabled: isSaving }"
|
||||
v-bind="isSaving ? { disabled: true } : {}"
|
||||
@@ -79,6 +81,15 @@
|
||||
{{ t('save') }}
|
||||
</span>
|
||||
</button>
|
||||
<a
|
||||
v-else
|
||||
:href="`/templates/${template.id}`"
|
||||
class="base-button"
|
||||
>
|
||||
<span class="hidden md:inline">
|
||||
{{ t('back') }}
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,7 +123,7 @@
|
||||
/>
|
||||
<div
|
||||
class="sticky bottom-0 py-2"
|
||||
:style="withStickySubmitters ? { backgroundColor } : {}"
|
||||
:style="{ backgroundColor }"
|
||||
>
|
||||
<Upload
|
||||
v-if="sortedDocuments.length && editable && withUploadButton"
|
||||
@@ -148,6 +159,7 @@
|
||||
:is-drag="!!dragField"
|
||||
:default-fields="defaultFields"
|
||||
:allow-draw="!onlyDefinedFields"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:draw-field="drawField"
|
||||
:editable="editable"
|
||||
:base-url="baseUrl"
|
||||
@@ -185,25 +197,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<MobileDrawField
|
||||
v-if="drawField && isBreakpointLg"
|
||||
:draw-field="drawField"
|
||||
:fields="template.fields"
|
||||
:submitters="template.submitters"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
class="md:hidden"
|
||||
:editable="editable"
|
||||
@cancel="[drawField = null, drawOption = null]"
|
||||
@change-submitter="[selectedSubmitter = $event, drawField.submitter_uuid = $event.uuid]"
|
||||
/>
|
||||
<MobileFields
|
||||
v-if="sortedDocuments.length && !drawField && editable"
|
||||
:fields="template.fields"
|
||||
:default-fields="defaultFields"
|
||||
:field-types="fieldTypes"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
@select="startFieldDraw($event)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="withFieldsList"
|
||||
@@ -259,6 +252,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sticky bottom-0">
|
||||
<MobileDrawField
|
||||
v-if="drawField && isBreakpointLg"
|
||||
:draw-field="drawField"
|
||||
:fields="template.fields"
|
||||
:submitters="template.submitters"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
class="md:hidden"
|
||||
:editable="editable"
|
||||
@cancel="[drawField = null, drawOption = null]"
|
||||
@change-submitter="[selectedSubmitter = $event, drawField.submitter_uuid = $event.uuid]"
|
||||
/>
|
||||
<MobileFields
|
||||
v-if="sortedDocuments.length && !drawField && editable"
|
||||
:fields="template.fields"
|
||||
:default-fields="defaultFields"
|
||||
:field-types="fieldTypes"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
@select="startFieldDraw($event)"
|
||||
/>
|
||||
</div>
|
||||
<div id="docuseal_modal_container" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -301,11 +316,14 @@ export default {
|
||||
template: this.template,
|
||||
save: this.save,
|
||||
t: this.t,
|
||||
currencies: this.currencies,
|
||||
baseFetch: this.baseFetch,
|
||||
fieldTypes: this.fieldTypes,
|
||||
backgroundColor: this.backgroundColor,
|
||||
withPhone: this.withPhone,
|
||||
withPayment: this.withPayment,
|
||||
withFormula: this.withFormula,
|
||||
defaultDrawFieldType: this.defaultDrawFieldType,
|
||||
selectedAreaRef: computed(() => this.selectedAreaRef)
|
||||
}
|
||||
},
|
||||
@@ -349,6 +367,16 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
defaultDrawFieldType: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'text'
|
||||
},
|
||||
currencies: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
fieldTypes: {
|
||||
type: Array,
|
||||
required: false,
|
||||
@@ -423,6 +451,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
withFormula: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
onlyDefinedFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -735,7 +768,9 @@ export default {
|
||||
|
||||
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
|
||||
|
||||
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
|
||||
if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
|
||||
type = this.defaultDrawFieldType
|
||||
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
|
||||
type = this.fieldTypes[0]
|
||||
}
|
||||
|
||||
@@ -884,7 +919,7 @@ export default {
|
||||
this.save()
|
||||
},
|
||||
onDocumentRemove (item) {
|
||||
if (window.confirm('Are you sure?')) {
|
||||
if (window.confirm(this.t('are_you_sure'))) {
|
||||
this.template.schema.splice(this.template.schema.indexOf(item), 1)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<IconPencil
|
||||
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
|
||||
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
|
||||
title="Edit"
|
||||
:title="t('edit')"
|
||||
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
||||
:width="iconWidth"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
@@ -44,6 +44,7 @@ export default {
|
||||
components: {
|
||||
IconPencil
|
||||
},
|
||||
inject: ['t'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
:allow-draw="allowDraw"
|
||||
:is-drag="isDrag"
|
||||
:default-fields="defaultFields"
|
||||
:default-submitters="defaultSubmitters"
|
||||
:draw-field="drawField"
|
||||
:selected-submitter="selectedSubmitter"
|
||||
:image="image"
|
||||
@@ -42,6 +43,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
allowDraw: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -96,7 +102,7 @@ export default {
|
||||
return this.previewImagesIndex[i] || {
|
||||
metadata: lazyloadMetadata,
|
||||
id: Math.random().toString(),
|
||||
url: this.basePreviewUrl + `/preview/${this.document.uuid}/${i}.jpg`
|
||||
url: this.basePreviewUrl + `/preview/${this.document.signed_uuid || this.document.uuid}/${i}.jpg`
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
>
|
||||
<button
|
||||
v-if="field && !field.areas.length"
|
||||
title="Draw"
|
||||
:title="t('draw')"
|
||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||
@click="$emit('set-draw', { field })"
|
||||
>
|
||||
@@ -65,6 +65,17 @@
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="field.preferences?.formula"
|
||||
class="relative cursor-pointer text-transparent group-hover:text-base-content"
|
||||
:disabled="!withFormula"
|
||||
@click="isShowFormulaModal = true"
|
||||
>
|
||||
<IconMathFunction
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
<PaymentSettings
|
||||
v-if="field.type === 'payment'"
|
||||
:field="field"
|
||||
@@ -77,7 +88,7 @@
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
title="Settings"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||
>
|
||||
<IconSettings
|
||||
@@ -142,6 +153,43 @@
|
||||
{{ t('format') }}
|
||||
</label>
|
||||
</div>
|
||||
<div
|
||||
v-if="field.type === 'signature'"
|
||||
class="py-1.5 px-1 relative"
|
||||
@click.stop
|
||||
>
|
||||
<select
|
||||
:placeholder="t('format')"
|
||||
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0"
|
||||
@change="[field.preferences.format = $event.target.value, save()]"
|
||||
>
|
||||
<option
|
||||
value="any"
|
||||
:selected="!field.preferences?.format || field.preferences.format === 'any'"
|
||||
>
|
||||
{{ t('any') }}
|
||||
</option>
|
||||
<option
|
||||
value="drawn"
|
||||
:selected="field.preferences?.format === 'drawn'"
|
||||
>
|
||||
{{ t('drawn') }}
|
||||
</option>
|
||||
<option
|
||||
value="typed"
|
||||
:selected="field.preferences?.format === 'typed'"
|
||||
>
|
||||
{{ t('typed') }}
|
||||
</option>
|
||||
</select>
|
||||
<label
|
||||
:style="{ backgroundColor: backgroundColor }"
|
||||
class="absolute -top-1 left-2.5 px-1 h-4"
|
||||
style="font-size: 8px"
|
||||
>
|
||||
{{ t('format') }}
|
||||
</label>
|
||||
</div>
|
||||
<li
|
||||
v-if="field.type != 'phone'"
|
||||
@click.stop
|
||||
@@ -156,6 +204,54 @@
|
||||
<span class="label-text">{{ t('required') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'number'"
|
||||
:class="{'tooltip tooltip-bottom': !withFormula}"
|
||||
:data-tip="withFormula ? '' : 'Available in Pro'"
|
||||
@click.stop
|
||||
>
|
||||
<label
|
||||
class="label-text cursor-pointer py-1.5 text-center w-full flex items-center"
|
||||
@click="isShowFormulaModal = withFormula"
|
||||
>
|
||||
<IconMathFunction
|
||||
width="18px"
|
||||
height="18px"
|
||||
class="ml-0.5 mr-1"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ t('formula') }}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'checkbox'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
v-model="field.default_value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@update:model-value="[field.default_value = $event, field.readonly = $event, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('checked') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type == 'date'"
|
||||
@click.stop
|
||||
>
|
||||
<label class="cursor-pointer py-1.5">
|
||||
<input
|
||||
v-model="field.readonly"
|
||||
type="checkbox"
|
||||
class="toggle toggle-xs"
|
||||
@update:model-value="[field.default_value = $event ? '{{date}}' : null, field.readonly = $event, save()]"
|
||||
>
|
||||
<span class="label-text">{{ t('set_signing_date') }}</span>
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="field.type === 'text' && !defaultField"
|
||||
@click.stop
|
||||
@@ -217,7 +313,7 @@
|
||||
</span>
|
||||
<button
|
||||
class="relative text-transparent group-hover:text-base-content pr-1"
|
||||
title="Remove"
|
||||
:title="t('remove')"
|
||||
@click="$emit('remove', field)"
|
||||
>
|
||||
<IconTrashX
|
||||
@@ -256,7 +352,7 @@
|
||||
@blur="save"
|
||||
>
|
||||
<button
|
||||
title="Draw"
|
||||
:title="t('draw')"
|
||||
tabindex="-1"
|
||||
@click.prevent="$emit('set-draw', { field, option })"
|
||||
>
|
||||
@@ -294,6 +390,16 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Teleport
|
||||
v-if="isShowFormulaModal"
|
||||
:to="modalContainerEl"
|
||||
>
|
||||
<FormulaModal
|
||||
:field="field"
|
||||
:build-default-name="buildDefaultName"
|
||||
@close="isShowFormulaModal = false"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -301,7 +407,8 @@
|
||||
import Contenteditable from './contenteditable'
|
||||
import FieldType from './field_type'
|
||||
import PaymentSettings from './payment_settings'
|
||||
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
|
||||
import FormulaModal from './formula_modal'
|
||||
import { IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
@@ -312,11 +419,13 @@ export default {
|
||||
IconShape,
|
||||
PaymentSettings,
|
||||
IconNewSection,
|
||||
FormulaModal,
|
||||
IconTrashX,
|
||||
IconMathFunction,
|
||||
IconCopy,
|
||||
FieldType
|
||||
},
|
||||
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't'],
|
||||
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef', 't', 'withFormula'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -338,11 +447,15 @@ export default {
|
||||
return {
|
||||
isNameFocus: false,
|
||||
showPaymentModal: false,
|
||||
isShowFormulaModal: false,
|
||||
renderDropdown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
},
|
||||
dateFormats () {
|
||||
return [
|
||||
'MM/DD/YYYY',
|
||||
@@ -357,22 +470,7 @@ export default {
|
||||
]
|
||||
},
|
||||
defaultName () {
|
||||
if (this.field.type === 'payment' && this.field.preferences?.price) {
|
||||
const { price, currency } = this.field.preferences || {}
|
||||
|
||||
const formattedPrice = new Intl.NumberFormat([], {
|
||||
style: 'currency',
|
||||
currency
|
||||
}).format(price)
|
||||
|
||||
return `${this.fieldNames[this.field.type]} ${formattedPrice}`
|
||||
} else {
|
||||
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
|
||||
|
||||
const suffix = { multiple: this.t('select'), radio: this.t('group') }[this.field.type] || this.t('field')
|
||||
|
||||
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
|
||||
}
|
||||
return this.buildDefaultName(this.field, this.template.fields)
|
||||
},
|
||||
areas () {
|
||||
return this.field.areas || []
|
||||
@@ -387,6 +485,24 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildDefaultName (field, fields) {
|
||||
if (field.type === 'payment' && field.preferences?.price) {
|
||||
const { price, currency } = field.preferences || {}
|
||||
|
||||
const formattedPrice = new Intl.NumberFormat([], {
|
||||
style: 'currency',
|
||||
currency
|
||||
}).format(price)
|
||||
|
||||
return `${this.fieldNames[field.type]} ${formattedPrice}`
|
||||
} else {
|
||||
const typeIndex = fields.filter((f) => f.type === field.type).indexOf(field)
|
||||
|
||||
const suffix = { multiple: this.t('select'), radio: this.t('group') }[field.type] || this.t('field')
|
||||
|
||||
return `${this.fieldNames[field.type]} ${suffix} ${typeIndex + 1}`
|
||||
}
|
||||
},
|
||||
formatDate (date, format) {
|
||||
const monthFormats = {
|
||||
M: 'numeric',
|
||||
@@ -420,11 +536,13 @@ export default {
|
||||
const areaString = JSON.stringify(field.areas[0])
|
||||
|
||||
this.template.documents.forEach((attachment) => {
|
||||
attachment.preview_images.forEach((page) => {
|
||||
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === parseInt(page.filename))) {
|
||||
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page: parseInt(page.filename) })
|
||||
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
|
||||
|
||||
for (let page = 0; page <= numberOfPages - 1; page++) {
|
||||
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
|
||||
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.$nextTick(() => {
|
||||
|
||||
@@ -61,13 +61,13 @@
|
||||
<button
|
||||
v-if="submitters.length > 1 && editable"
|
||||
class="px-2"
|
||||
@click.stop="remove(submitter)"
|
||||
@click.prevent.stop="remove(submitter)"
|
||||
>
|
||||
<IconTrashX :width="18" />
|
||||
</button>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="submitters.length < 10 && editable">
|
||||
<li v-if="submitters.length < names.length && editable">
|
||||
<a
|
||||
href="#"
|
||||
class="flex px-2"
|
||||
@@ -125,7 +125,8 @@
|
||||
/>
|
||||
</div>
|
||||
<span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 flex justify-center items-center rounded">
|
||||
<IconPlus
|
||||
<component
|
||||
:is="editable ? 'IconPlus' : 'IconChevronDown'"
|
||||
width="18"
|
||||
height="18"
|
||||
/>
|
||||
@@ -144,7 +145,7 @@
|
||||
<a
|
||||
href="#"
|
||||
class="flex px-2 group justify-between items-center"
|
||||
:class="{ 'active': submitter === selectedSubmitter }"
|
||||
:class="{ 'active': submitter === selectedSubmitter, 'py-0.5': submitters.length > 8 }"
|
||||
@click.prevent="selectSubmitter(submitter)"
|
||||
>
|
||||
<span class="py-1 flex items-center">
|
||||
@@ -162,18 +163,18 @@
|
||||
>
|
||||
<div class="flex-col pr-1 hidden group-hover:flex -mt-1 h-0">
|
||||
<button
|
||||
title="Up"
|
||||
:title="t('up')"
|
||||
class="relative w-2"
|
||||
style="font-size: 10px; margin-bottom: -4px"
|
||||
@click.stop="[move(submitter, -1), $refs.label.focus()] "
|
||||
@click.prevent.stop="[move(submitter, -1), $refs.label.focus()] "
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
title="Down"
|
||||
:title="t('down')"
|
||||
class="relative w-2"
|
||||
style="font-size: 10px; margin-top: -4px"
|
||||
@click.stop="[move(submitter, 1), $refs.label.focus()] "
|
||||
@click.prevent.stop="[move(submitter, 1), $refs.label.focus()] "
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
@@ -181,14 +182,14 @@
|
||||
<button
|
||||
v-if="!compact && submitters.length > 1 && editable"
|
||||
class="hidden group-hover:block px-2"
|
||||
@click.stop="remove(submitter)"
|
||||
@click.prevent.stop="remove(submitter)"
|
||||
>
|
||||
<IconTrashX :width="18" />
|
||||
</button>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li v-if="submitters.length < 10 && editable">
|
||||
<li v-if="submitters.length < names.length && editable && allowAddNew">
|
||||
<a
|
||||
href="#"
|
||||
class="flex px-2"
|
||||
@@ -208,7 +209,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconUserPlus, IconTrashX, IconPlus, IconChevronUp } from '@tabler/icons-vue'
|
||||
import { IconUserPlus, IconTrashX, IconPlus, IconChevronUp, IconChevronDown } from '@tabler/icons-vue'
|
||||
import Contenteditable from './contenteditable'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
@@ -216,6 +217,7 @@ export default {
|
||||
name: 'FieldSubmitter',
|
||||
components: {
|
||||
IconUserPlus,
|
||||
IconChevronDown,
|
||||
Contenteditable,
|
||||
IconPlus,
|
||||
IconTrashX,
|
||||
@@ -242,6 +244,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
allowAddNew: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true
|
||||
@@ -261,6 +268,16 @@ export default {
|
||||
computed: {
|
||||
colors () {
|
||||
return [
|
||||
'bg-red-500',
|
||||
'bg-sky-500',
|
||||
'bg-emerald-500',
|
||||
'bg-yellow-300',
|
||||
'bg-purple-600',
|
||||
'bg-pink-500',
|
||||
'bg-cyan-500',
|
||||
'bg-orange-500',
|
||||
'bg-lime-500',
|
||||
'bg-indigo-500',
|
||||
'bg-red-500',
|
||||
'bg-sky-500',
|
||||
'bg-emerald-500',
|
||||
@@ -284,7 +301,17 @@ export default {
|
||||
this.t('seventh_party'),
|
||||
this.t('eighth_party'),
|
||||
this.t('ninth_party'),
|
||||
this.t('tenth_party')
|
||||
this.t('tenth_party'),
|
||||
this.t('eleventh_party'),
|
||||
this.t('twelfth_party'),
|
||||
this.t('thirteenth_party'),
|
||||
this.t('fourteenth_party'),
|
||||
this.t('fifteenth_party'),
|
||||
this.t('sixteenth_party'),
|
||||
this.t('seventeenth_party'),
|
||||
this.t('eighteenth_party'),
|
||||
this.t('nineteenth_party'),
|
||||
this.t('twentieth_party')
|
||||
]
|
||||
},
|
||||
selectedSubmitter () {
|
||||
@@ -296,7 +323,7 @@ export default {
|
||||
this.$emit('update:model-value', submitter.uuid)
|
||||
},
|
||||
remove (submitter) {
|
||||
if (window.confirm('Are you sure?')) {
|
||||
if (window.confirm(this.t('are_you_sure'))) {
|
||||
this.$emit('remove', submitter)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<template
|
||||
v-for="(icon, type) in fieldIcons"
|
||||
v-for="(icon, type) in fieldIconsSorted"
|
||||
:key="type"
|
||||
>
|
||||
<li v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')">
|
||||
@@ -50,7 +50,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp } from '@tabler/icons-vue'
|
||||
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard, IconRubberStamp, IconSquareNumber1 } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FiledTypeDropdown',
|
||||
@@ -94,6 +94,7 @@ export default {
|
||||
signature: this.t('signature'),
|
||||
initials: this.t('initials'),
|
||||
date: this.t('date'),
|
||||
number: this.t('number'),
|
||||
image: this.t('image'),
|
||||
file: this.t('file'),
|
||||
select: this.t('select'),
|
||||
@@ -112,17 +113,29 @@ export default {
|
||||
signature: IconWritingSign,
|
||||
initials: IconLetterCaseUpper,
|
||||
date: IconCalendarEvent,
|
||||
number: IconSquareNumber1,
|
||||
image: IconPhoto,
|
||||
file: IconPaperclip,
|
||||
checkbox: IconCheckbox,
|
||||
multiple: IconChecks,
|
||||
file: IconPaperclip,
|
||||
radio: IconCircleDot,
|
||||
select: IconSelect,
|
||||
multiple: IconChecks,
|
||||
cells: IconColumns3,
|
||||
stamp: IconRubberStamp,
|
||||
payment: IconCreditCard,
|
||||
phone: IconPhoneCheck
|
||||
}
|
||||
},
|
||||
fieldIconsSorted () {
|
||||
if (this.fieldTypes.length) {
|
||||
return this.fieldTypes.reduce((acc, type) => {
|
||||
acc[type] = this.fieldIcons[type]
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
} else {
|
||||
return this.fieldIcons
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
class="grid grid-cols-3 gap-1 pb-2"
|
||||
>
|
||||
<template
|
||||
v-for="(icon, type) in fieldIcons"
|
||||
v-for="(icon, type) in fieldIconsSorted"
|
||||
:key="type"
|
||||
>
|
||||
<button
|
||||
@@ -93,7 +93,7 @@
|
||||
<div
|
||||
v-else-if="type == 'phone' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
|
||||
class="tooltip tooltip-bottom flex"
|
||||
:class="{'tooltip-bottom-start': !withPayment, 'tooltip-bottom': withPayment }"
|
||||
:class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }"
|
||||
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
|
||||
>
|
||||
<a
|
||||
@@ -213,6 +213,17 @@ export default {
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
fieldIconsSorted () {
|
||||
if (this.fieldTypes.length) {
|
||||
return this.fieldTypes.reduce((acc, type) => {
|
||||
acc[type] = this.fieldIcons[type]
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
} else {
|
||||
return this.fieldIcons
|
||||
}
|
||||
},
|
||||
submitterFields () {
|
||||
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<div
|
||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<span>
|
||||
{{ t('formula') }}
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
class="text-xl"
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex-inline mb-2 gap-2 space-y-1">
|
||||
<button
|
||||
v-for="f in fields"
|
||||
:key="f.uuid"
|
||||
class="mr-1 btn btn-neutral btn-outline border-base-content/20 btn-sm normal-case font-normal bg-white !rounded-xl"
|
||||
@click.prevent="insertTextUnderCursor(`{{${f.name || buildDefaultName(f, template.fields)}}}`)"
|
||||
>
|
||||
<IconCodePlus
|
||||
width="20"
|
||||
height="20"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
{{ f.name || buildDefaultName(f, template.fields) }}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex">
|
||||
<textarea
|
||||
ref="textarea"
|
||||
v-model="formula"
|
||||
class="base-textarea !rounded-xl !text-base font-mono w-full !outline-0 !ring-0 !px-3"
|
||||
required="true"
|
||||
@input="resizeTextarea"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3 mt-1">
|
||||
<div
|
||||
target="blank"
|
||||
class="text-sm mb-2 inline space-x-2"
|
||||
>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' + ')"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' - ')"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' * ')"
|
||||
>
|
||||
*
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' / ')"
|
||||
>
|
||||
/
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor(' % ')"
|
||||
>
|
||||
%
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('^')"
|
||||
>
|
||||
^
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('round()')"
|
||||
>
|
||||
round(n, d)
|
||||
</button>
|
||||
<button
|
||||
class="bg-base-200 px-2 rounded-xl"
|
||||
@click="insertTextUnderCursor('abs()')"
|
||||
>
|
||||
abs(n)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="base-button w-full"
|
||||
@click.prevent="validateSaveAndClose"
|
||||
>
|
||||
{{ t('save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconCodePlus } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'FormulaModal',
|
||||
components: {
|
||||
IconCodePlus
|
||||
},
|
||||
inject: ['t', 'save', 'template'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
buildDefaultName: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['close'],
|
||||
data () {
|
||||
return {
|
||||
formula: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fields () {
|
||||
return this.template.fields.reduce((acc, f) => {
|
||||
if (f !== this.field && f.submitter_uuid === this.field.submitter_uuid && ['number'].includes(f.type) && !f.preferences?.formula) {
|
||||
acc.push(f)
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.field.preferences ||= {}
|
||||
},
|
||||
mounted () {
|
||||
this.formula = this.humanizeFormula(this.field.preferences.formula || '')
|
||||
},
|
||||
methods: {
|
||||
humanizeFormula (text) {
|
||||
return text.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
const foundField = this.fields.find((f) => f.uuid === uuid)
|
||||
|
||||
if (foundField) {
|
||||
return `{{${foundField.name || this.buildDefaultName(foundField, this.template.fields)}}}`
|
||||
} else {
|
||||
return '{{FIELD NOT FOUND}}'
|
||||
}
|
||||
})
|
||||
},
|
||||
normalizeFormula (text) {
|
||||
return text.replace(/{{(.*?)}}/g, (match, name) => {
|
||||
const foundField = this.fields.find((f) => {
|
||||
return (f.name || this.buildDefaultName(f, this.template.fields)).trim() === name.trim()
|
||||
})
|
||||
|
||||
if (foundField) {
|
||||
return `{{${foundField.uuid}}}`
|
||||
} else {
|
||||
return '{{FIELD NOT FOUND}}'
|
||||
}
|
||||
})
|
||||
},
|
||||
validateSaveAndClose () {
|
||||
const normalizedFormula = this.normalizeFormula(this.formula)
|
||||
|
||||
if (normalizedFormula.includes('FIELD NOT FOUND')) {
|
||||
alert('Some fields are missing in the formula.')
|
||||
} else {
|
||||
this.field.preferences.formula = normalizedFormula
|
||||
this.field.readonly = !!normalizedFormula
|
||||
|
||||
this.save()
|
||||
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
insertTextUnderCursor (textToInsert) {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
const selectionEnd = textarea.selectionEnd
|
||||
const cursorPos = selectionEnd
|
||||
|
||||
const newText = textarea.value.substring(0, cursorPos) + textToInsert + textarea.value.substring(cursorPos)
|
||||
|
||||
this.formula = newText
|
||||
|
||||
textarea.setSelectionRange(cursorPos + textToInsert.length, cursorPos + textToInsert.length)
|
||||
|
||||
textarea.focus()
|
||||
},
|
||||
resizeTextarea () {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,20 @@
|
||||
const en = {
|
||||
are_you_sure: 'Are you sure?',
|
||||
sign_yourself: 'Sign Yourself',
|
||||
set_signing_date: 'Set signing date',
|
||||
send: 'Send',
|
||||
remove: 'Remove',
|
||||
edit: 'Edit',
|
||||
settings: 'Settings',
|
||||
up: 'Up',
|
||||
down: 'Down',
|
||||
checked: 'Checked',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
any: 'Any',
|
||||
drawn: 'Drawn',
|
||||
formula: 'Formula',
|
||||
typed: 'Typed',
|
||||
draw_field_on_the_document: 'Draw {field} field on the document',
|
||||
click_to_upload: 'Click to upload',
|
||||
or_drag_and_drop_files: 'or drag and drop files',
|
||||
@@ -29,9 +41,21 @@ const en = {
|
||||
eighth_party: 'Eighth Party',
|
||||
ninth_party: 'Ninth Party',
|
||||
tenth_party: 'Tenth Party',
|
||||
eleventh_party: 'Eleventh Party',
|
||||
twelfth_party: 'Twelfth Party',
|
||||
thirteenth_party: 'Thirteenth Party',
|
||||
fourteenth_party: 'Fourteenth Party',
|
||||
fifteenth_party: 'Fifteenth Party',
|
||||
sixteenth_party: 'Sixteenth Party',
|
||||
seventeenth_party: 'Seventeenth Party',
|
||||
eighteenth_party: 'Eighteenth Party',
|
||||
nineteenth_party: 'Nineteenth Party',
|
||||
twentieth_party: 'Twentieth Party',
|
||||
draw: 'Draw',
|
||||
add: 'Add',
|
||||
or_add_field_without_drawing: 'Or add field without drawing',
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
signature: 'Signature',
|
||||
initials: 'Initials',
|
||||
date: 'Date',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="fixed text-center w-full bottom-0 pr-6 mb-4">
|
||||
<div class="absolute text-center w-full bottom-0 pr-3 mb-4">
|
||||
<span class="w-full bg-base-200 px-4 py-2 rounded-md inline-flex space-x-2 mx-auto items-center justify-between mb-2 z-20 md:hidden">
|
||||
<div class="flex items-center space-x-2">
|
||||
<component
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<span
|
||||
class="dropdown dropdown-top dropdown-end fixed bottom-4 right-4 z-10 md:hidden"
|
||||
class="dropdown dropdown-top dropdown-end absolute bottom-4 right-4 z-10 md:hidden"
|
||||
>
|
||||
<label
|
||||
class="btn btn-neutral text-white btn-circle btn-lg group"
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
:field="item.field"
|
||||
:editable="editable"
|
||||
:default-field="defaultFields.find((f) => f.name === item.field.name)"
|
||||
:default-submitters="defaultSubmitters"
|
||||
@start-resize="resizeDirection = $event"
|
||||
@stop-resize="resizeDirection = null"
|
||||
@start-drag="isMove = true"
|
||||
@@ -60,7 +61,7 @@ export default {
|
||||
components: {
|
||||
FieldArea
|
||||
},
|
||||
inject: ['fieldTypes'],
|
||||
inject: ['fieldTypes', 'defaultDrawFieldType'],
|
||||
props: {
|
||||
image: {
|
||||
type: Object,
|
||||
@@ -85,6 +86,11 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
defaultSubmitters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
drawField: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -117,7 +123,9 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
defaultFieldType () {
|
||||
if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
|
||||
if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
|
||||
return this.defaultDrawFieldType
|
||||
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
|
||||
return this.fieldTypes[0]
|
||||
} else {
|
||||
return 'text'
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
title="Settings"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||
>
|
||||
<IconSettings
|
||||
@@ -31,7 +31,7 @@
|
||||
@change="save"
|
||||
>
|
||||
<option
|
||||
v-for="currency in currencies"
|
||||
v-for="currency in currenciesList"
|
||||
:key="currency"
|
||||
:value="currency"
|
||||
>
|
||||
@@ -167,7 +167,7 @@ export default {
|
||||
IconInnerShadowTop,
|
||||
IconBrandStripe
|
||||
},
|
||||
inject: ['backgroundColor', 'save'],
|
||||
inject: ['backgroundColor', 'save', 'currencies', 't'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -187,9 +187,12 @@ export default {
|
||||
redirectUri () {
|
||||
return document.location.origin + '/auth/stripe_connect/callback'
|
||||
},
|
||||
currencies () {
|
||||
defaultCurrencies () {
|
||||
return ['USD', 'EUR', 'GBP']
|
||||
},
|
||||
currenciesList () {
|
||||
return this.currencies.length ? this.currencies : this.defaultCurrencies
|
||||
},
|
||||
authenticityToken () {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.content
|
||||
},
|
||||
|
||||
@@ -26,12 +26,14 @@ class ProcessSubmitterCompletionJob < ApplicationJob
|
||||
|
||||
user = submission.created_by_user || submitter.template.author
|
||||
|
||||
if submitter.template.account.users.exists?(id: user.id) &&
|
||||
submission.preferences['send_email'] != false
|
||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
||||
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false
|
||||
if submitter.submission.submitters.map(&:email).exclude?(user.email) &&
|
||||
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false
|
||||
SubmitterMailer.completed_email(submitter, user).deliver_later!
|
||||
end
|
||||
|
||||
bcc = submission.preferences['bcc_completed'].presence ||
|
||||
submission.template.account.account_configs
|
||||
submission.account.account_configs
|
||||
.find_by(key: AccountConfig::BCC_EMAILS)&.value.presence
|
||||
|
||||
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
class SendFormCompletedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(submitter, params = {})
|
||||
attempt = params[:attempt].to_i
|
||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||
|
||||
return if config.blank? || config.value.blank?
|
||||
@@ -12,13 +15,26 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.completed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
resp = begin
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.completed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
rescue Faraday::TimeoutError
|
||||
nil
|
||||
end
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
|
||||
SendFormCompletedWebhookRequestJob.set(wait: (2**attempt).minutes)
|
||||
.perform_later(submitter, {
|
||||
attempt: attempt + 1,
|
||||
last_status: resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,20 +3,36 @@
|
||||
class SendFormStartedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(submitter, params = {})
|
||||
attempt = params[:attempt].to_i
|
||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||
|
||||
return if config.blank? || config.value.blank?
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.started',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
resp = begin
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.started',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
rescue Faraday::TimeoutError
|
||||
nil
|
||||
end
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
|
||||
SendFormStartedWebhookRequestJob.set(wait: (2**attempt).minutes)
|
||||
.perform_later(submitter, {
|
||||
attempt: attempt + 1,
|
||||
last_status: resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,20 +3,36 @@
|
||||
class SendFormViewedWebhookRequestJob < ApplicationJob
|
||||
USER_AGENT = 'DocuSeal.co Webhook'
|
||||
|
||||
def perform(submitter)
|
||||
MAX_ATTEMPTS = 10
|
||||
|
||||
def perform(submitter, params = {})
|
||||
attempt = params[:attempt].to_i
|
||||
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||
|
||||
return if config.blank? || config.value.blank?
|
||||
|
||||
ActiveStorage::Current.url_options = Docuseal.default_url_options
|
||||
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.viewed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
resp = begin
|
||||
Faraday.post(config.value,
|
||||
{
|
||||
event_type: 'form.viewed',
|
||||
timestamp: Time.current,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT)
|
||||
rescue Faraday::TimeoutError
|
||||
nil
|
||||
end
|
||||
|
||||
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
|
||||
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
|
||||
SendFormViewedWebhookRequestJob.set(wait: (2**attempt).minutes)
|
||||
.perform_later(submitter, {
|
||||
attempt: attempt + 1,
|
||||
last_status: resp&.status.to_i
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,11 @@ class SendSubmitterInvitationEmailJob < ApplicationJob
|
||||
def perform(params = {})
|
||||
submitter = Submitter.find(params['submitter_id'])
|
||||
|
||||
SubmitterMailer.invitation_email(submitter).deliver_now!
|
||||
mail = SubmitterMailer.invitation_email(submitter)
|
||||
|
||||
Submitters::ValidateSending.call(submitter, mail)
|
||||
|
||||
mail.deliver_now!
|
||||
|
||||
SubmissionEvent.create!(submitter:, event_type: 'send_email')
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
class SubmitterMailer < ApplicationMailer
|
||||
MAX_ATTACHMENTS_SIZE = 10.megabytes
|
||||
SIGN_TTL = 1.hour + 20.minutes
|
||||
|
||||
DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
|
||||
|
||||
@@ -61,9 +62,10 @@ class SubmitterMailer < ApplicationMailer
|
||||
subject:)
|
||||
end
|
||||
|
||||
def documents_copy_email(submitter, to: nil)
|
||||
def documents_copy_email(submitter, to: nil, sig: false)
|
||||
@current_account = submitter.submission.template.account
|
||||
@submitter = submitter
|
||||
@sig = submitter.signed_id(expires_in: SIGN_TTL, purpose: :download_completed) if sig
|
||||
|
||||
Submissions::EnsureResultGenerated.call(@submitter)
|
||||
|
||||
|
||||
@@ -22,12 +22,30 @@ class Account < ApplicationRecord
|
||||
class_name: 'TemplateFolder', dependent: :destroy, inverse_of: :account
|
||||
has_many :submissions, through: :templates
|
||||
has_many :submitters, through: :submissions
|
||||
has_many :account_linked_accounts, dependent: :destroy
|
||||
has_many :account_testing_accounts, -> { testing }, dependent: :destroy,
|
||||
class_name: 'AccountLinkedAccount',
|
||||
inverse_of: :account
|
||||
has_one :linked_account_account, dependent: :destroy,
|
||||
foreign_key: :linked_account_id,
|
||||
class_name: 'AccountLinkedAccount',
|
||||
inverse_of: :linked_account
|
||||
has_many :linked_account_accounts, dependent: :destroy,
|
||||
foreign_key: :linked_account_id,
|
||||
class_name: 'AccountLinkedAccount',
|
||||
inverse_of: :linked_account
|
||||
has_many :linked_accounts, through: :account_linked_accounts
|
||||
has_many :testing_accounts, through: :account_testing_accounts, source: :linked_account
|
||||
has_many :active_users, -> { active }, dependent: :destroy,
|
||||
inverse_of: :account, class_name: 'User'
|
||||
|
||||
attribute :timezone, :string, default: 'UTC'
|
||||
attribute :locale, :string, default: 'en-US'
|
||||
|
||||
def testing?
|
||||
linked_account_account&.testing?
|
||||
end
|
||||
|
||||
def default_template_folder
|
||||
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
|
||||
author_id: users.minimum(:id)).tap(&:save!)
|
||||
|
||||
@@ -27,8 +27,10 @@ class AccountConfig < ApplicationRecord
|
||||
BCC_EMAILS = 'bcc_emails'
|
||||
FORCE_MFA = 'force_mfa'
|
||||
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
|
||||
ALLOW_TO_RESUBMIT = 'allow_to_resubmit'
|
||||
SUBMITTER_REMAILERS = 'submitter_reminders'
|
||||
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
|
||||
FORM_WITH_CONFETTI_KEY = 'form_with_confetti'
|
||||
ESIGNING_PREFERENCE_KEY = 'esigning_preference'
|
||||
|
||||
DEFAULT_VALUES = {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_linked_accounts
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# account_type :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# linked_account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_on_account_id_linked_account_id_48ab9f79d2 (account_id,linked_account_id) UNIQUE
|
||||
# index_account_linked_accounts_on_account_id (account_id)
|
||||
# index_account_linked_accounts_on_linked_account_id (linked_account_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (account_id => accounts.id)
|
||||
# fk_rails_... (linked_account_id => accounts.id)
|
||||
#
|
||||
class AccountLinkedAccount < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :linked_account, class_name: 'Account'
|
||||
|
||||
attribute :account_type, :string, default: 'testing'
|
||||
|
||||
scope :testing, -> { where(account_type: :testing) }
|
||||
|
||||
def testing?
|
||||
account_type == 'testing'
|
||||
end
|
||||
end
|
||||
@@ -15,11 +15,13 @@
|
||||
# template_submitters :text
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# created_by_user_id :bigint
|
||||
# template_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_submissions_on_account_id (account_id)
|
||||
# index_submissions_on_created_by_user_id (created_by_user_id)
|
||||
# index_submissions_on_slug (slug) UNIQUE
|
||||
# index_submissions_on_template_id (template_id)
|
||||
@@ -31,7 +33,7 @@
|
||||
#
|
||||
class Submission < ApplicationRecord
|
||||
belongs_to :template
|
||||
has_one :account, through: :template
|
||||
belongs_to :account
|
||||
belongs_to :created_by_user, class_name: 'User', optional: true
|
||||
|
||||
has_many :submitters, dependent: :destroy
|
||||
@@ -74,7 +76,7 @@ class Submission < ApplicationRecord
|
||||
def audit_trail_url
|
||||
return if audit_trail.blank?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_storage_proxy_url(audit_trail, **Docuseal.default_url_options)
|
||||
ActiveStorage::Blob.proxy_url(audit_trail.blob)
|
||||
end
|
||||
alias audit_log_url audit_trail_url
|
||||
end
|
||||
|
||||
@@ -46,7 +46,8 @@ class SubmissionEvent < ApplicationRecord
|
||||
phone_verified: 'phone_verified',
|
||||
start_form: 'start_form',
|
||||
view_form: 'view_form',
|
||||
complete_form: 'complete_form'
|
||||
complete_form: 'complete_form',
|
||||
api_complete_form: 'api_complete_form'
|
||||
}, scope: false
|
||||
|
||||
private
|
||||
|
||||
+25
-18
@@ -4,23 +4,24 @@
|
||||
#
|
||||
# Table name: submitters
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# application_key :string
|
||||
# completed_at :datetime
|
||||
# email :string
|
||||
# ip :string
|
||||
# name :string
|
||||
# opened_at :datetime
|
||||
# phone :string
|
||||
# preferences :text not null
|
||||
# sent_at :datetime
|
||||
# slug :string not null
|
||||
# ua :string
|
||||
# uuid :string not null
|
||||
# values :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# submission_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# completed_at :datetime
|
||||
# email :string
|
||||
# ip :string
|
||||
# metadata :text not null
|
||||
# name :string
|
||||
# opened_at :datetime
|
||||
# phone :string
|
||||
# preferences :text not null
|
||||
# sent_at :datetime
|
||||
# slug :string not null
|
||||
# ua :string
|
||||
# uuid :string not null
|
||||
# values :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# external_id :string
|
||||
# submission_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -35,14 +36,16 @@
|
||||
class Submitter < ApplicationRecord
|
||||
belongs_to :submission
|
||||
has_one :template, through: :submission
|
||||
has_one :account, through: :template
|
||||
has_one :account, through: :submission
|
||||
|
||||
attribute :values, :string, default: -> { {} }
|
||||
attribute :preferences, :string, default: -> { {} }
|
||||
attribute :metadata, :string, default: -> { {} }
|
||||
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
|
||||
|
||||
serialize :values, coder: JSON
|
||||
serialize :preferences, coder: JSON
|
||||
serialize :metadata, coder: JSON
|
||||
|
||||
has_many_attached :documents
|
||||
has_many_attached :attachments
|
||||
@@ -62,6 +65,10 @@ class Submitter < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def application_key
|
||||
external_id
|
||||
end
|
||||
|
||||
def friendly_name
|
||||
if name.present? && email.present? && email.exclude?(',')
|
||||
%("#{name.delete('"')}" <#{email}>)
|
||||
|
||||
+19
-14
@@ -4,20 +4,20 @@
|
||||
#
|
||||
# Table name: templates
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# application_key :string
|
||||
# archived_at :datetime
|
||||
# fields :text not null
|
||||
# name :string not null
|
||||
# schema :text not null
|
||||
# slug :string not null
|
||||
# source :text not null
|
||||
# submitters :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# author_id :bigint not null
|
||||
# folder_id :bigint not null
|
||||
# id :bigint not null, primary key
|
||||
# archived_at :datetime
|
||||
# fields :text not null
|
||||
# name :string not null
|
||||
# schema :text not null
|
||||
# slug :string not null
|
||||
# source :text not null
|
||||
# submitters :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# author_id :bigint not null
|
||||
# external_id :string
|
||||
# folder_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
@@ -57,10 +57,15 @@ class Template < ApplicationRecord
|
||||
class_name: 'ActiveStorage::Attachment', dependent: :destroy, as: :record, inverse_of: :record
|
||||
|
||||
has_many :submissions, dependent: :destroy
|
||||
has_many :template_sharings, dependent: :destroy
|
||||
|
||||
scope :active, -> { where(archived_at: nil) }
|
||||
scope :archived, -> { where.not(archived_at: nil) }
|
||||
|
||||
def application_key
|
||||
external_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def maybe_set_default_folder
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: template_sharings
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# ability :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# template_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_template_sharings_on_account_id_and_template_id (account_id,template_id) UNIQUE
|
||||
# index_template_sharings_on_template_id (template_id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (template_id => templates.id)
|
||||
#
|
||||
class TemplateSharing < ApplicationRecord
|
||||
ALL_ID = -1
|
||||
|
||||
belongs_to :template
|
||||
belongs_to :account, optional: true
|
||||
end
|
||||
+2
-2
@@ -59,7 +59,7 @@ class User < ApplicationRecord
|
||||
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
|
||||
has_many :email_messages, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
|
||||
|
||||
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
|
||||
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable, :lockable
|
||||
|
||||
attribute :role, :string, default: ADMIN_ROLE
|
||||
attribute :uuid, :string, default: -> { SecureRandom.uuid }
|
||||
@@ -72,7 +72,7 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
def active_for_authentication?
|
||||
!archived_at?
|
||||
super && !archived_at?
|
||||
end
|
||||
|
||||
def remember_me
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#
|
||||
class UserConfig < ApplicationRecord
|
||||
SIGNATURE_KEY = 'signature'
|
||||
RECEIVE_COMPLETED_EMAIL = 'receive_completed_email'
|
||||
|
||||
belongs_to :user
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<% encrypted_config = @encrypted_config || EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) %>
|
||||
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) %>
|
||||
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) && !current_account.testing? %>
|
||||
<%= f.fields_for encrypted_config do |ff| %>
|
||||
<div class="form-control">
|
||||
<%= ff.label :value, 'App URL', class: 'label' %>
|
||||
@@ -63,6 +63,24 @@
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_RESUBMIT) %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span>
|
||||
Allow to resubmit completed forms
|
||||
</span>
|
||||
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if can?(:manage, current_account) && Docuseal.multitenant? && true_user == current_user %>
|
||||
<div class="px-1 mt-8">
|
||||
<h2 class="text-2xl font-bold mb-2">Danger Zone</h2>
|
||||
<%= button_to button_title(title: 'Delete my account'), settings_account_path, class: 'btn btn-outline btn-error block', data: { turbo_confirm: 'Schedule account for deletion?' }, method: :delete, id: :account_delete_button %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="flex-grow">
|
||||
<h1 class="text-4xl font-bold mb-4">API</h1>
|
||||
<div class="flex justify-between">
|
||||
<h1 class="text-4xl font-bold mb-4">API</h1>
|
||||
<%= render 'shared/test_mode_toggle' %>
|
||||
</div>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-6">
|
||||
<label for="api_key" class="text-sm font-semibold">X-Auth-Token</label>
|
||||
<div class="flex flex-row space-x-4">
|
||||
<input id="api_key" type="text" value="<%= current_user.access_token.token %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
|
||||
<%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
|
||||
<%= button_to button_title(title: 'Rotate', disabled_with: 'Rotate', icon: svg_icon('reload', class: 'w-6 h-6')), settings_api_index_path, class: 'white-button', data: { turbo_confirm: 'Remove existing API token and generated a new one. Are you sure?' } %>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex w-full space-x-4">
|
||||
<input id="api_key" type="text" value="<%= current_user.access_token.token %>" class="input font-mono input-bordered w-full" autocomplete="off" readonly>
|
||||
<%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
|
||||
</div>
|
||||
<%= button_to button_title(title: 'Rotate', disabled_with: 'Rotate', icon: svg_icon('reload', class: 'w-6 h-6')), settings_api_index_path, class: 'white-button w-full', data: { turbo_confirm: 'Remove existing API token and generated a new one. Are you sure?' } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,20 +35,16 @@
|
||||
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \
|
||||
--data-raw '{
|
||||
"template_id": <%= current_account.templates.last&.id || 1 %>,
|
||||
"submission": [
|
||||
"submitters": [
|
||||
{
|
||||
"submitters": [
|
||||
{
|
||||
"name": "John Doe",
|
||||
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Party' %>",
|
||||
"email": "<%= current_user.email.sub('@', '+test@') %>",
|
||||
"values": {
|
||||
"Form Text Field Name": "Default Value"
|
||||
}
|
||||
},
|
||||
{ "role": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
|
||||
]
|
||||
}
|
||||
"name": "John Doe",
|
||||
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Party' %>",
|
||||
"email": "<%= current_user.email.sub('@', '+test@') %>",
|
||||
"values": {
|
||||
"Form Text Field Name": "Default Value"
|
||||
}
|
||||
},
|
||||
{ "role": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
|
||||
]
|
||||
}'<% end.to_str %>
|
||||
<span class="top-0 right-0 absolute">
|
||||
|
||||
@@ -1,101 +1,80 @@
|
||||
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
|
||||
<% if @pagy.count > 0 || params[:q].present? || @template_folders.present? %>
|
||||
<div class="flex justify-between mb-4 items-center">
|
||||
<h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
|
||||
<div class="flex space-x-2">
|
||||
<% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %>
|
||||
<%= render 'shared/search_input' %>
|
||||
<div class="flex justify-between mb-4 items-center">
|
||||
<h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
|
||||
<div class="flex space-x-2">
|
||||
<% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %>
|
||||
<%= render 'shared/search_input' %>
|
||||
<% end %>
|
||||
<% if can?(:create, ::Template) %>
|
||||
<%= render 'templates/upload_button' %>
|
||||
<%= link_to new_template_path, class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %>
|
||||
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
|
||||
<span class="hidden md:block">Create</span>
|
||||
<% end %>
|
||||
<% if can?(:create, ::Template) %>
|
||||
<%= render 'templates/upload_button' %>
|
||||
<%= link_to new_template_path, class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %>
|
||||
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
|
||||
<span class="hidden md:block">Create</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% view_archived_html = capture do %>
|
||||
<% if current_account.templates.where.not(archived_at: nil).exists? %>
|
||||
<div>
|
||||
<a href="<%= templates_archived_index_path %>" class="link text-sm">View Archived</a>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @template_folders.present? %>
|
||||
<div class="grid gap-4 md:grid-cols-3 <%= 'mb-6' if @templates.present? %>">
|
||||
<%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %>
|
||||
</div>
|
||||
<% view_archived_html = capture do %>
|
||||
<% if current_account.templates.where.not(archived_at: nil).exists? %>
|
||||
<div>
|
||||
<a href="<%= templates_archived_index_path %>" class="link text-sm">View Archived</a>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @templates.present? %>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<%= render partial: 'templates/template', collection: @templates %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
|
||||
<%= form_for '', url: templates_upload_path, id: form_id = SecureRandom.uuid, method: :post, class: 'mt-8 block', html: { enctype: 'multipart/form-data' } do %>
|
||||
<input type="hidden" name="form_id" value="<%= form_id %>">
|
||||
<button type="submit" class="hidden"></button>
|
||||
<file-dropzone data-submit-on-upload="true" class="w-full">
|
||||
<label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border border-2 border-base-300 border-dashed">
|
||||
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<span data-target="file-dropzone.icon" class="flex flex-col items-center">
|
||||
<span>
|
||||
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
|
||||
</span>
|
||||
<div class="font-medium mb-1">
|
||||
Upload New Document
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<span class="font-medium">Click to upload</span> or drag and drop
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @template_folders.present? %>
|
||||
<div class="grid gap-4 md:grid-cols-3 <%= 'mb-6' if @templates.present? %>">
|
||||
<%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @templates.present? %>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<%= render partial: 'templates/template', collection: @templates %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
|
||||
<%= form_for '', url: templates_upload_path, id: form_id = SecureRandom.uuid, method: :post, class: 'mt-8 block', html: { enctype: 'multipart/form-data' } do %>
|
||||
<input type="hidden" name="form_id" value="<%= form_id %>">
|
||||
<button type="submit" class="hidden"></button>
|
||||
<file-dropzone data-submit-on-upload="true" class="w-full">
|
||||
<label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border border-2 border-base-300 border-dashed">
|
||||
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<span data-target="file-dropzone.icon" class="flex flex-col items-center">
|
||||
<span>
|
||||
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
|
||||
</span>
|
||||
<span data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
|
||||
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
|
||||
<div class="font-medium mb-1">
|
||||
Uploading...
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
|
||||
</div>
|
||||
</label>
|
||||
</file-dropzone>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @templates.present? || params[:q].blank? %>
|
||||
<% if @pagy.pages > 1 %>
|
||||
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %>
|
||||
<% else %>
|
||||
<div class="mt-2">
|
||||
<%= view_archived_html %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% elsif params[:q].present? %>
|
||||
<div class="text-center">
|
||||
<div class="mt-16 text-3xl font-semibold">
|
||||
Templates not Found
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="card bg-base-200 h-96 mb-2">
|
||||
<div class="card-body text-center">
|
||||
<div class="flex items-center h-full">
|
||||
<div class="mx-auto">
|
||||
<div class="max-w-xl mx-auto">
|
||||
<h1 class="text-5xl font-bold text-base-content">👋 Welcome to <%= Docuseal.product_name %></h1>
|
||||
</div>
|
||||
<div class="max-w-lg mx-auto">
|
||||
<p class="py-6 text-gray-600">Streamline document workflows, from creating customizable templates to filling and signing document forms</p>
|
||||
<%= link_to new_template_path, class: 'base-button', data: { turbo_frame: :modal } do %>
|
||||
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
|
||||
<span class="mr-1">Create Template</span>
|
||||
<% end %>
|
||||
<div class="font-medium mb-1">
|
||||
Upload New Document
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<span class="font-medium">Click to upload</span> or drag and drop
|
||||
</div>
|
||||
</span>
|
||||
<span data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
|
||||
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
|
||||
<div class="font-medium mb-1">
|
||||
Uploading...
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</file-dropzone>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if @templates.present? || params[:q].blank? %>
|
||||
<% if @pagy.pages > 1 %>
|
||||
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %>
|
||||
<% else %>
|
||||
<div class="mt-2">
|
||||
<%= view_archived_html %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% elsif params[:q].present? %>
|
||||
<div class="text-center">
|
||||
<div class="mt-16 text-3xl font-semibold">
|
||||
Templates not Found
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<div class="max-w-lg mx-auto px-2">
|
||||
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
|
||||
<h1 class="text-3xl font-bold text-center mt-8 mb-6">Create Free Account</h1>
|
||||
<% if params[:redir].to_s.ends_with?('on_premise') %>
|
||||
<h1 class="text-3xl font-bold text-center mt-8 mb-6">DocuSeal Console</h1>
|
||||
<div class="alert my-4 text-sm">
|
||||
Sign up in DocuSeal Console to upgrade.
|
||||
On-premises app is completely standalone, Console is used only to manage your license.
|
||||
</div>
|
||||
<% else %>
|
||||
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
|
||||
<h1 class="text-3xl font-bold text-center mt-8 mb-6">Create Free Account</h1>
|
||||
<% end %>
|
||||
<%= form_for(User.new, html: { class: 'space-y-6' }, url: new_registration_path, data: { turbo: params[:redir].blank? }, method: :get) do |f| %>
|
||||
<% if params[:redir].present? %>
|
||||
<%= hidden_field_tag :redir, params[:redir] %>
|
||||
@@ -17,15 +25,19 @@
|
||||
<% end %>
|
||||
<% if devise_mapping.omniauthable? %>
|
||||
<div class="space-y-4">
|
||||
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
|
||||
<set-timezone data-input-id="state" data-params="true"></set-timezone>
|
||||
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
|
||||
<%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
|
||||
<% if User.omniauth_providers.include?(:google_oauth2) %>
|
||||
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
|
||||
<set-timezone data-input-id="state" data-params="true"></set-timezone>
|
||||
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
|
||||
<%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
|
||||
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
|
||||
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
|
||||
<%= f.button button_title(title: 'Sign up with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
|
||||
<% if User.omniauth_providers.include?(:microsoft_office365) %>
|
||||
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
|
||||
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
|
||||
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
|
||||
<%= f.button button_title(title: 'Sign up with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -21,15 +21,19 @@
|
||||
<% end %>
|
||||
<% if devise_mapping.omniauthable? %>
|
||||
<div class="space-y-4">
|
||||
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
|
||||
<set-timezone data-input-id="state" data-params="true"></set-timezone>
|
||||
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
|
||||
<%= f.button button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
|
||||
<% if User.omniauth_providers.include?(:google_oauth2) %>
|
||||
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
|
||||
<set-timezone data-input-id="state" data-params="true"></set-timezone>
|
||||
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
|
||||
<%= f.button button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
|
||||
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
|
||||
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
|
||||
<%= f.button button_title(title: 'Sign in with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
|
||||
<% if User.omniauth_providers.include?(:microsoft_office365) %>
|
||||
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
|
||||
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
|
||||
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
|
||||
<%= f.button button_title(title: 'Sign in with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<% end %>
|
||||
<div class="space-y-2">
|
||||
<div class="form-control">
|
||||
<%= f.label :otp_attempt, 'Two-Factor Code', class: 'label' %>
|
||||
<%= f.label :otp_attempt, 'Two-Factor Code from Authenticator App', class: 'label' %>
|
||||
<%= f.text_field :otp_attempt, autofocus: true, placeholder: 'XXX-XXX', required: true, class: 'base-input' %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -123,26 +123,24 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if !Docuseal.multitenant? %>
|
||||
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::ESIGNING_PREFERENCE_KEY).first_or_initialize(value: 'multiple') %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<div class="px-1 mt-8 max-w-xl">
|
||||
<div class="flex justify-between items-end mb-4 mt-8">
|
||||
<h2 class="text-3xl font-bold">Preferences</h2>
|
||||
</div>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span>
|
||||
Apply multiple PDF digital signatures in the document per each signer
|
||||
</span>
|
||||
<%= f.check_box :value, { class: 'toggle', checked: account_config.value == 'multiple', onchange: 'this.form.requestSubmit()' }, 'multiple', 'single' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::ESIGNING_PREFERENCE_KEY).first_or_initialize(value: 'multiple') %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<div class="px-1 mt-8 max-w-xl">
|
||||
<div class="flex justify-between items-end mb-4 mt-8">
|
||||
<h2 class="text-3xl font-bold">Preferences</h2>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between py-2.5">
|
||||
<span>
|
||||
Apply multiple PDF digital signatures in the document per each signer
|
||||
</span>
|
||||
<%= f.check_box :value, { class: 'toggle', checked: account_config.value == 'multiple', onchange: 'this.form.requestSubmit()' }, 'multiple', 'single' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 13h5" />
|
||||
<path d="M12 16v-8h3a2 2 0 0 1 2 2v1a2 2 0 0 1 -2 2h-3" />
|
||||
<path d="M20 8v8" />
|
||||
<path d="M9 16v-5.5a2.5 2.5 0 0 0 -5 0v5.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M15 6l-6 6l6 6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 313 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M7 8l-4 4l4 4" />
|
||||
<path d="M17 8l4 4l-4 4" />
|
||||
<path d="M14 4l-4 16" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 369 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M8.5 13.5l-1.5 -1.5l1.5 -1.5" />
|
||||
<path d="M15.5 10.5l1.5 1.5l-1.5 1.5" />
|
||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||
<path d="M13 9.5l-2 5.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 458 B |
@@ -3,7 +3,7 @@
|
||||
Use an authenticator mobile app like Google Authenticator or 1Password to scan the QR code below.
|
||||
</p>
|
||||
<div>
|
||||
<%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'w-80 h-80 my-4 mx-auto' }) %>
|
||||
<%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'md:w-80 md:h-80 my-4 mx-auto' }) %>
|
||||
</div>
|
||||
<div class="form-control my-6 space-y-2">
|
||||
<%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="form-control">
|
||||
<% record = Struct.new(:first_duration, :second_duration, :third_duration).new(*(f.object.value || {}).values_at('first_duration', 'second_duration', 'third_duration')) %>
|
||||
<div class="flex space-x-4">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="w-full">
|
||||
<%= f.fields_for :value, record do |ff| %>
|
||||
<%= ff.label :first_duration, 'First reminder in', class: 'label' %>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<p>
|
||||
Send automatic email reminders to your recipients.
|
||||
<br>
|
||||
<a class="link font-medium" target="_blank" href="<%= "#{Docuseal::CONSOLE_URL}/#{Docuseal.multitenant? ? 'plans' : 'on_premise'}" %>">Learn More</a>
|
||||
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>">Learn More</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
<%= render 'shared/settings_nav' %>
|
||||
<div class="flex-grow max-w-xl mx-auto">
|
||||
<h1 class="text-4xl font-bold mb-4">Email Notifications</h1>
|
||||
<div class="mt-2 mb-1">
|
||||
<% user_config = UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::RECEIVE_COMPLETED_EMAIL) %>
|
||||
<% if can?(:manage, user_config) %>
|
||||
<%= form_for user_config, url: user_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between">
|
||||
<span>
|
||||
Receive notification emails on completed submission
|
||||
</span>
|
||||
<%= f.check_box :value, class: 'toggle', checked: user_config.value != false, onchange: 'this.form.requestSubmit()' %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render 'email_stats' %>
|
||||
<%= render 'bcc_form', config: @bcc_config %>
|
||||
<div class="flex justify-between items-end mb-4 mt-8">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<p>
|
||||
Display your company name and logo when signing documents.
|
||||
<br>
|
||||
<a class="link font-medium" target="_blank" href="<%= "#{Docuseal::CONSOLE_URL}/#{Docuseal.multitenant? ? 'plans' : 'on_premise'}" %>">Learn More</a>
|
||||
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>">Learn More</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %>
|
||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %>
|
||||
Upgrade
|
||||
<% end %>
|
||||
<% if signed_in? && current_user != true_user %>
|
||||
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
|
||||
<%= render 'shared/test_alert' %>
|
||||
<% end %>
|
||||
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user