Merge remote-tracking branch 'origin/master'

pull/402/head
Vincent Barrier 8 months ago
commit 9990411528

@ -7,13 +7,13 @@ jobs:
name: Rubocop name: Rubocop
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.4.1 ruby-version: 3.4.1
- name: Cache gems - name: Cache gems
uses: actions/cache@v1 uses: actions/cache@v4
with: with:
path: vendor/bundle path: vendor/bundle
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
@ -31,13 +31,13 @@ jobs:
name: Erblint name: Erblint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 3.4.1 ruby-version: 3.4.1
- name: Cache gems - name: Cache gems
uses: actions/cache@v1 uses: actions/cache@v4
with: with:
path: vendor/bundle path: vendor/bundle
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}
@ -49,13 +49,13 @@ jobs:
bundle config path vendor/bundle bundle config path vendor/bundle
bundle install --jobs 4 --retry 4 bundle install --jobs 4 --retry 4
- name: Run Erblint - name: Run Erblint
run: bundle exec erblint ./app run: bundle exec erb_lint ./app
eslint: eslint:
name: ESLint name: ESLint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Install Node.js - name: Install Node.js
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
@ -63,7 +63,7 @@ jobs:
- name: Cache directory path - name: Cache directory path
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)" run: echo "::set-output name=dir::$(yarn cache dir)"
- uses: actions/cache@v1 - uses: actions/cache@v4
id: yarn-cache id: yarn-cache
with: with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }} path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
@ -96,7 +96,7 @@ jobs:
--health-retries 5 --health-retries 5
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- name: Install Ruby - name: Install Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
@ -110,12 +110,12 @@ jobs:
with: with:
chrome-version: 125 chrome-version: 125
- name: Cache node_modules - name: Cache node_modules
uses: actions/cache@v1 uses: actions/cache@v4
with: with:
path: node_modules path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
- name: Cache gems - name: Cache gems
uses: actions/cache@v1 uses: actions/cache@v4
with: with:
path: vendor/bundle path: vendor/bundle
key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }} key: ${{ runner.os }}-gem-${{ hashFiles('**/Gemfile.lock') }}

@ -25,22 +25,22 @@ jobs:
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Create .version file - name: Create .version file
run: echo ${{ github.ref_name }} > .version run: echo ${{ github.ref_name }} > .version
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v4 uses: docker/build-push-action@v6
with: with:
context: . context: .
push: true push: true

@ -223,9 +223,12 @@ GEM
concurrent-ruby (~> 1.1) concurrent-ruby (~> 1.1)
webrick (~> 1.7) webrick (~> 1.7)
websocket-driver (~> 0.7) websocket-driver (~> 0.7)
ffi (1.17.0) ffi (1.17.1)
ffi (1.17.0-aarch64-linux-gnu) ffi (1.17.1-aarch64-linux-gnu)
ffi (1.17.0-x86_64-linux-gnu) ffi (1.17.1-aarch64-linux-musl)
ffi (1.17.1-arm64-darwin)
ffi (1.17.1-x86_64-linux-gnu)
ffi (1.17.1-x86_64-linux-musl)
geom2d (0.4.1) geom2d (0.4.1)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
@ -324,7 +327,7 @@ GEM
mysql2 (0.5.6) mysql2 (0.5.6)
net-http-persistent (4.0.5) net-http-persistent (4.0.5)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.5.3) net-imap (0.5.6)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@ -334,12 +337,18 @@ GEM
net-smtp (0.5.0) net-smtp (0.5.0)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.4)
nokogiri (1.17.2) nokogiri (1.18.2)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.17.2-aarch64-linux) nokogiri (1.18.2-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.17.2-x86_64-linux) nokogiri (1.18.2-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.2-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
oj (3.16.8) oj (3.16.8)
bigdecimal (>= 3.0) bigdecimal (>= 3.0)
@ -377,7 +386,7 @@ GEM
puma (6.5.0) puma (6.5.0)
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.8.1) racc (1.8.1)
rack (3.1.8) rack (3.1.10)
rack-proxy (0.7.7) rack-proxy (0.7.7)
rack rack
rack-session (2.0.0) rack-session (2.0.0)
@ -520,10 +529,13 @@ GEM
simplecov-html (0.13.1) simplecov-html (0.13.1)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
sqlite3 (2.4.1) sqlite3 (2.5.0)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
sqlite3 (2.4.1-aarch64-linux-gnu) sqlite3 (2.5.0-aarch64-linux-gnu)
sqlite3 (2.4.1-x86_64-linux-gnu) sqlite3 (2.5.0-aarch64-linux-musl)
sqlite3 (2.5.0-arm64-darwin)
sqlite3 (2.5.0-x86_64-linux-gnu)
sqlite3 (2.5.0-x86_64-linux-musl)
stringio (3.1.2) stringio (3.1.2)
strip_attributes (1.14.1) strip_attributes (1.14.1)
activemodel (>= 3.0, < 9.0) activemodel (>= 3.0, < 9.0)
@ -569,8 +581,11 @@ GEM
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
aarch64-linux-musl
arm64-darwin
ruby ruby
x86_64-linux x86_64-linux
x86_64-linux-musl
DEPENDENCIES DEPENDENCIES
annotate annotate

@ -50,7 +50,7 @@ class AccountsController < ApplicationController
# rubocop:disable Layout/LineLength # rubocop:disable Layout/LineLength
render turbo_stream: turbo_stream.replace( render turbo_stream: turbo_stream.replace(
:account_delete_button, :account_delete_button,
html: helpers.tag.p(I18n.t('your_account_removal_request_will_be_processed_within_2_weeks_please_contact_us_if_you_want_to_keep_your_account')) html: helpers.tag.p(I18n.t('your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account'))
) )
# rubocop:enable Layout/LineLength # rubocop:enable Layout/LineLength
end end

@ -10,7 +10,7 @@ module Api
last_submitter = @submission.submitters.max_by(&:completed_at) last_submitter = @submission.submitters.max_by(&:completed_at)
if last_submitter.documents_attachments.blank? if last_submitter.documents_attachments.blank?
last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(submitter) last_submitter.documents_attachments = Submissions::EnsureResultGenerated.call(last_submitter)
end end
last_submitter.documents_attachments last_submitter.documents_attachments

@ -34,6 +34,8 @@ module Api
} }
end end
} }
rescue HexaPDF::MalformedPDFError
render json: { error: 'Malformed PDF' }, status: :unprocessable_entity
end end
end end
end end

@ -30,7 +30,7 @@ class ApplicationController < ActionController::Base
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
end end
if Rails.env.production? if Rails.env.production? || Rails.env.test?
rescue_from CanCan::AccessDenied do |e| rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)

@ -0,0 +1,4 @@
# frozen_string_literal: true
class PwaController < ActionController::Base
end

@ -9,8 +9,6 @@ class SendSubmissionEmailController < ApplicationController
SEND_DURATION = 30.minutes SEND_DURATION = 30.minutes
def success; end
def create def create
@submitter = @submitter =
if params[:template_slug] if params[:template_slug]
@ -31,7 +29,7 @@ class SendSubmissionEmailController < ApplicationController
end end
respond_to do |f| respond_to do |f|
f.html { redirect_to success_send_submission_email_index_path } f.html { render :success }
f.json { head :ok } f.json { head :ok }
end end
end end

@ -34,6 +34,8 @@ class StartFormController < ApplicationController
assign_submission_attributes(@submitter, @template) assign_submission_attributes(@submitter, @template)
Submissions::AssignDefinedSubmitters.call(@submitter.submission) Submissions::AssignDefinedSubmitters.call(@submitter.submission)
else
@submitter.assign_attributes(ip: request.remote_ip, ua: request.user_agent)
end end
if @submitter.save if @submitter.save
@ -64,8 +66,10 @@ class StartFormController < ApplicationController
.or(template.submissions.where(expire_at: nil)).where(archived_at: nil)) .or(template.submissions.where(expire_at: nil)).where(archived_at: nil))
.order(id: :desc) .order(id: :desc)
.where(declined_at: nil) .where(declined_at: nil)
.where(external_id: nil)
.where(ip: [nil, request.remote_ip])
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel } .then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(**submitter_params.compact_blank) .find_or_initialize_by(email: submitter_params[:email], **submitter_params.compact_blank)
end end
def assign_submission_attributes(submitter, template) def assign_submission_attributes(submitter, template)

@ -0,0 +1,25 @@
# frozen_string_literal: true
class SubmissionEventsController < ApplicationController
SUBMISSION_EVENT_ICONS = {
'view_form' => 'eye',
'start_form' => 'player_play',
'complete_form' => 'check',
'send_email' => 'mail_forward',
'click_email' => 'hand_click',
'api_complete_form' => 'check',
'send_reminder_email' => 'mail_forward',
'send_2fa_sms' => '2fa',
'send_sms' => 'send',
'phone_verified' => 'phone_check',
'click_sms' => 'hand_click',
'decline_form' => 'x',
'start_verification' => 'player_play',
'complete_verification' => 'check',
'invite_party' => 'user_plus'
}.freeze
load_and_authorize_resource :submission
def index; end
end

@ -20,7 +20,10 @@ class SubmissionsPreviewController < ApplicationController
@submission ||= Submission.find_by!(slug: params[:slug]) @submission ||= Submission.find_by!(slug: params[:slug])
if @submission.account.archived_at? || (!@submission.submitters.all?(&:completed_at?) && current_user.blank?) raise ActionController::RoutingError if @submission.account.archived_at?
if !@submission.submitters.all?(&:completed_at?) && !signature_valid &&
(!current_user || !current_ability.can?(:read, @submission))
raise ActionController::RoutingError, I18n.t('not_found') raise ActionController::RoutingError, I18n.t('not_found')
end end
@ -37,6 +40,7 @@ class SubmissionsPreviewController < ApplicationController
def completed def completed
@submission = Submission.find_by!(slug: params[:submissions_preview_slug]) @submission = Submission.find_by!(slug: params[:submissions_preview_slug])
@template = @submission.template
render :completed, layout: 'form' render :completed, layout: 'form'
end end

@ -0,0 +1,58 @@
# frozen_string_literal: true
class SubmittersResubmitController < ApplicationController
load_and_authorize_resource :submitter, parent: false
def update
return redirect_to submit_form_path(slug: @submitter.slug) if @submitter.email != current_user.email
submission = @submitter.template.submissions.new(created_by_user: current_user,
submitters_order: :preserved,
**@submitter.submission.slice(:template_fields,
:account_id,
:template_schema,
:template_submitters,
:preferences))
@submitter.submission.submitters.each do |submitter|
new_submitter = submission.submitters.new(submitter.slice(:uuid, :email, :phone, :name,
:preferences, :metadata, :account_id))
next unless submitter.uuid == @submitter.uuid
assign_submitter_values(new_submitter, submitter)
@new_submitter ||= new_submitter
end
submission.save!
redirect_to submit_form_path(slug: @new_submitter.slug)
end
private
def assign_submitter_values(new_submitter, submitter)
attachments_index = submitter.attachments.index_by(&:uuid)
submitter.submission.template_fields.each do |field|
next if field['submitter_uuid'] != submitter.uuid
next if field['default_value'] == '{{date}}'
next if field['type'] == 'stamp'
next if field['type'] == 'signature'
next if field.dig('preferences', 'formula').present?
value = submitter.values[field['uuid']]
next if value.blank?
if field['type'].in?(%w[image file initials])
Array.wrap(value).each do |attachment_uuid|
new_submitter.attachments << attachments_index[attachment_uuid].dup
end
end
new_submitter.values[field['uuid']] = value
end
end
end

@ -28,8 +28,10 @@ class TemplatesPreferencesController < ApplicationController
submitters_order submitters_order
completed_notification_email_subject completed_notification_email_body completed_notification_email_subject completed_notification_email_body
completed_notification_email_enabled completed_notification_email_attach_audit] + completed_notification_email_enabled completed_notification_email_attach_audit] +
[completed_message: %i[title body]] [completed_message: %i[title body],
submitters: [%i[uuid request_email_subject request_email_body]]]
).tap do |attrs| ).tap do |attrs|
attrs[:preferences].delete(:submitters) if params[:request_email_per_submitter] != '1'
attrs[:preferences] = attrs[:preferences].transform_values do |value| attrs[:preferences] = attrs[:preferences].transform_values do |value|
if %w[true false].include?(value) if %w[true false].include?(value)
value == 'true' value == 'true'

@ -3,6 +3,8 @@ export default class extends HTMLElement {
this.resize() this.resize()
this.textarea.addEventListener('input', () => this.resize()) this.textarea.addEventListener('input', () => this.resize())
this.observeVisibility()
} }
resize () { resize () {
@ -11,6 +13,28 @@ export default class extends HTMLElement {
} }
} }
observeVisibility () {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.resize()
this.observer.unobserve(this.textarea)
}
})
},
{
threshold: 0.1
}
)
this.observer.observe(this.textarea)
}
disconnectedCallback () {
this.observer.unobserve(this.textarea)
}
get textarea () { get textarea () {
return this.querySelector('textarea') return this.querySelector('textarea')
} }

@ -3,7 +3,7 @@
class="field-area flex absolute lg:text-base -outline-offset-1" class="field-area flex absolute lg:text-base -outline-offset-1"
dir="auto" dir="auto"
:style="computedStyle" :style="computedStyle"
:class="{ 'font-serif': field.preferences?.font === 'Times', 'text-[1.6vw] 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, 'field-area-active outline-red-500 outline-dashed outline-2 z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }" :class="{ 'font-mono': field.preferences?.font === 'Courier', 'font-serif': field.preferences?.font === 'Times', 'text-[1.6vw] 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, 'field-area-active outline-red-500 outline-dashed outline-2 z-10': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
> >
<div <div
v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid" v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@ -191,10 +191,16 @@
class="whitespace-pre-wrap text-gray-400" class="whitespace-pre-wrap text-gray-400"
:class="{ 'w-full': field.preferences?.align }" :class="{ 'w-full': field.preferences?.align }"
>{{ field.name }}</span> >{{ field.name }}</span>
<span v-else-if="Array.isArray(modelValue)"> <span
v-else-if="Array.isArray(modelValue)"
:class="{ 'w-full': field.preferences?.align }"
>
{{ modelValue.join(', ') }} {{ modelValue.join(', ') }}
</span> </span>
<span v-else-if="field.type === 'date'"> <span
v-else-if="field.type === 'date'"
:class="{ 'w-full': field.preferences?.align }"
>
{{ formattedDate }} {{ formattedDate }}
</span> </span>
<span <span
@ -450,8 +456,18 @@ export default {
} }
}, },
formatNumber (number, format) { formatNumber (number, format) {
if (!number && number !== 0) {
return ''
}
if (format === 'comma') { if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number) return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'usd') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'gbp') {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'eur') {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'dot') { } else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number) return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') { } else if (format === 'space') {

@ -31,7 +31,7 @@
:with-label="withLabel && !withFieldPlaceholder && step.length < 2" :with-label="withLabel && !withFieldPlaceholder && step.length < 2"
:is-value-set="step.some((f) => f.uuid in values)" :is-value-set="step.some((f) => f.uuid in values)"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
@click="$emit('focus-step', stepIndex)" @click="[$emit('focus-step', stepIndex), maybeScrollOnClick(field, area)]"
/> />
</Teleport> </Teleport>
</template> </template>
@ -109,6 +109,14 @@ export default {
areaRefs: [] areaRefs: []
} }
}, },
computed: {
isMobileContainer () {
const root = this.$root.$el.parentNode.getRootNode()
const container = root.body || root.querySelector('div')
return container.style.overflow === 'hidden'
}
},
beforeUpdate () { beforeUpdate () {
this.areaRefs = [] this.areaRefs = []
}, },
@ -121,14 +129,16 @@ export default {
this.scrollIntoArea(field.areas[0]) this.scrollIntoArea(field.areas[0])
} }
}, },
maybeScrollOnClick (field, area) {
if (['text', 'number', 'cells'].includes(field.type) && this.isMobileContainer) {
this.scrollIntoArea(area)
}
},
scrollIntoArea (area) { scrollIntoArea (area) {
const areaRef = this.areaRefs.find((a) => a.area === area) const areaRef = this.areaRefs.find((a) => a.area === area)
if (areaRef) { if (areaRef) {
const root = this.$root.$el.parentNode.getRootNode() if (this.isMobileContainer) {
const container = root.body || root.querySelector('div')
if (container.style.overflow === 'hidden') {
this.scrollInContainer(areaRef.$el) this.scrollInContainer(areaRef.$el)
} else { } else {
const targetRect = areaRef.$refs.scrollToElem.getBoundingClientRect() const targetRect = areaRef.$refs.scrollToElem.getBoundingClientRect()
@ -157,13 +167,15 @@ export default {
const formContainer = root.getElementById('form_container') const formContainer = root.getElementById('form_container')
const container = root.body || root.querySelector('div') const container = root.body || root.querySelector('div')
const padding = 64 const isAndroid = /android/i.test(navigator.userAgent)
const padding = isAndroid ? 128 : 64
const scrollboxTop = isAndroid ? scrollbox.getBoundingClientRect().top : 0
const boxRect = scrollbox.children[0].getBoundingClientRect() const boxRect = scrollbox.children[0].getBoundingClientRect()
const targetRect = target.getBoundingClientRect() const targetRect = target.getBoundingClientRect()
const targetTopRelativeToBox = targetRect.top - boxRect.top const targetTopRelativeToBox = targetRect.top - boxRect.top
scrollbox.scrollTo({ top: targetTopRelativeToBox - container.offsetHeight + formContainer.offsetHeight + target.offsetHeight + padding, behavior: 'smooth' }) scrollbox.scrollTo({ top: targetTopRelativeToBox + scrollboxTop - container.offsetHeight + formContainer.offsetHeight + target.offsetHeight + padding, behavior: 'smooth' })
}, },
setAreaRef (el) { setAreaRef (el) {
if (el) { if (el) {

@ -49,7 +49,7 @@
<MarkdownContent :string="field.description" /> <MarkdownContent :string="field.description" />
</div> </div>
<FileDropzone <FileDropzone
:message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`" :message="`${t('upload')} ${(field.title || field.name) || t('files')}${field.required ? '' : ` (${t('optional')})`}`"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:multiple="true" :multiple="true"
:dry-run="dryRun" :dry-run="dryRun"

@ -1,22 +1,28 @@
<template> <template>
<div dir="auto"> <div dir="auto">
<div <div
class="flex justify-between items-center w-full" class="flex justify-between items-end w-full mb-3.5 sm:mb-4"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
> >
<label <label
v-if="showFieldNames"
:for="field.uuid" :for="field.uuid"
class="label text-2xl" class="label text-xl sm:text-2xl py-0"
> >
<MarkdownContent <MarkdownContent
v-if="field.title" v-if="field.title"
:string="field.title" :string="field.title"
/> />
<template v-else> <template v-else>
{{ field.name && showFieldNames ? field.name : t('date') }} {{ field.name || t('date') }}
<template v-if="!field.required"> </template>
<template v-if="!field.required">
<span
class="ml-1"
:class="{ 'hidden sm:inline': (field.title || field.name || t('date') ).length > 10 }"
>
({{ t('optional') }}) ({{ t('optional') }})
</template> </span>
</template> </template>
</label> </label>
<button <button

@ -147,7 +147,7 @@ export default {
} }
}) })
} else { } else {
if (file.type === 'image/bmp') { if (file.type === 'image/bmp' || file.type === 'image/vnd.microsoft.icon') {
file = await this.convertBmpToPng(file) file = await this.convertBmpToPng(file)
} }

@ -100,7 +100,8 @@
<button <button
v-if="!isCompleted" v-if="!isCompleted"
id="minimize_form_button" id="minimize_form_button"
class="absolute right-0 mr-2 mt-2 top-0 hidden md:block" class="absolute right-0 top-0"
:class="currentField?.description?.length > 100 ? 'mr-1 mt-1 md:mr-2 md:mt-2': 'mr-2 mt-2 hidden md:block'"
:title="t('minimize')" :title="t('minimize')"
@click.prevent="minimizeForm" @click.prevent="minimizeForm"
> >
@ -164,7 +165,7 @@
v-if="showFieldNames && (currentField.name || currentField.title)" v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid" :for="currentField.uuid"
dir="auto" dir="auto"
class="label text-2xl" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5"
:class="{ 'mb-2': !currentField.description }" :class="{ 'mb-2': !currentField.description }"
> >
<MarkdownContent <MarkdownContent
@ -173,7 +174,11 @@
/> />
<template v-else> <template v-else>
{{ currentField.name }} {{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template> </template>
<template v-if="!currentField.required">
<span :class="{ 'hidden sm:inline': (currentField.title || currentField.name).length > 20 }">
({{ t('optional') }})
</span>
</template> </template>
</label> </label>
<div <div
@ -221,7 +226,7 @@
v-if="showFieldNames && (currentField.name || currentField.title)" v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid" :for="currentField.uuid"
dir="auto" dir="auto"
class="label text-2xl" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5"
:class="{ 'mb-2': !currentField.description }" :class="{ 'mb-2': !currentField.description }"
> >
<MarkdownContent <MarkdownContent
@ -230,7 +235,11 @@
/> />
<template v-else> <template v-else>
{{ currentField.name }} {{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template> </template>
<template v-if="!currentField.required">
<span :class="{ 'hidden sm:inline': (currentField.title || currentField.name).length > 20 }">
({{ t('optional') }})
</span>
</template> </template>
</label> </label>
<div <div
@ -466,7 +475,7 @@
</div> </div>
<div <div
v-if="(currentField.type !== 'payment' && currentField.type !== 'verification') || submittedValues[currentField.uuid]" v-if="(currentField.type !== 'payment' && currentField.type !== 'verification') || submittedValues[currentField.uuid]"
:class="currentField.type === 'signature' ? 'mt-2' : 'mt-6 md:mt-8'" :class="currentField.type === 'signature' ? 'mt-2' : 'mt-4 md:mt-6'"
> >
<button <button
id="submit_form_button" id="submit_form_button"
@ -522,9 +531,9 @@
/> />
<div <div
v-if="stepFields.length < 80" v-if="stepFields.length < 80"
class="flex justify-center" class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1"
> >
<div class="flex items-center mt-4 mb-1 flex-wrap"> <div class="flex items-center flex-wrap">
<a <a
v-for="(step, index) in stepFields" v-for="(step, index) in stepFields"
:key="step[0].uuid" :key="step[0].uuid"
@ -1113,9 +1122,9 @@ export default {
this.minimizeForm() this.minimizeForm()
} }
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent) const isMobile = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit|android/i.test(navigator.userAgent)
if (isMobileSafariIos || /iPhone|iPad|iPod/i.test(navigator.userAgent)) { if (isMobile || /iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.$nextTick(() => { this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode() const root = this.$root.$el.parentNode.getRootNode()
const scrollbox = root.getElementById('scrollbox') const scrollbox = root.getElementById('scrollbox')

@ -1,9 +1,19 @@
<template> <template>
<div v-if="modelValue"> <div v-if="modelValue">
<div class="flex justify-between items-center w-full mb-2"> <div class="flex justify-between items-end w-full mb-3.5 md:mb-4">
<label <label
class="label text-2xl" v-if="showFieldNames"
>{{ showFieldNames && field.name ? field.name : t('image') }}</label> :for="field.uuid"
class="label text-xl sm:text-2xl py-0"
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ field.name || t('image') }}
</template>
</label>
<button <button
class="btn btn-outline btn-sm" class="btn btn-outline btn-sm"
@click.prevent="remove" @click.prevent="remove"
@ -36,7 +46,7 @@
</div> </div>
<FileDropzone <FileDropzone
v-if="!field.preferences.only_with_camera || (isMobile && field.preferences.only_with_camera)" v-if="!field.preferences.only_with_camera || (isMobile && field.preferences.only_with_camera)"
:message="`${field.preferences?.only_with_camera ? t('take') : t('upload')} ${field.name || (field.preferences?.only_with_camera ? t('photo') : t('image'))}${field.required ? '' : ` (${t('optional')})`}`" :message="`${field.preferences?.only_with_camera ? t('take') : t('upload')} ${(field.title || field.name) || (field.preferences?.only_with_camera ? t('photo') : t('image'))}${field.required ? '' : ` (${t('optional')})`}`"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:dry-run="dryRun" :dry-run="dryRun"
:accept="'image/*'" :accept="'image/*'"

@ -1,21 +1,22 @@
<template> <template>
<div dir="auto"> <div dir="auto">
<div <div
class="flex justify-between items-center w-full" class="flex justify-between items-end w-full mb-3.5 md:mb-4"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
> >
<label <label
class="label text-2xl" v-if="showFieldNames"
class="label text-xl sm:text-2xl py-0"
> >
<MarkdownContent <MarkdownContent
v-if="field.title" v-if="field.title"
:string="field.title" :string="field.title"
/> />
<template v-else> <template v-else>
{{ showFieldNames && field.name ? field.name : t('initials') }} {{ field.name || t('initials') }}
</template> </template>
</label> </label>
<div class="space-x-2 flex"> <div class="space-x-2 flex flex-none">
<span <span
v-if="isDrawInitials" v-if="isDrawInitials"
class="tooltip" class="tooltip"

@ -40,7 +40,7 @@
> >
</div> </div>
<div <div
class="mt-6 md:mt-8" class="mt-4 md:mt-6"
> >
<button <button
type="submit" type="submit"

@ -3,13 +3,19 @@
v-if="showFieldNames && (field.name || field.title)" v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid" :for="field.uuid"
dir="auto" dir="auto"
class="label text-2xl" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
><MarkdownContent >
v-if="field.title" <MarkdownContent
:string="field.title" v-if="field.title"
/> :string="field.title"
/>
<template v-else>{{ field.name }}</template> <template v-else>{{ field.name }}</template>
<template v-if="!field.required">
<span :class="{ 'hidden sm:inline': (field.title || field.name).length > 20 }">
({{ t('optional') }})
</span>
</template>
</label> </label>
<div <div
v-if="field.description" v-if="field.description"

@ -3,15 +3,18 @@
v-if="showFieldNames && (field.name || field.title)" v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid" :for="field.uuid"
dir="auto" dir="auto"
class="label text-2xl" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
><MarkdownContent >
v-if="field.title" <MarkdownContent
:string="field.title" v-if="field.title"
/> :string="field.title"
/>
<template v-else>{{ field.name }}</template> <template v-else>{{ field.name }}</template>
<template v-if="!field.required"> <template v-if="!field.required">
({{ t('optional') }}) <span :class="{ 'hidden sm:inline': (field.title || field.name).length > 20 }">
({{ t('optional') }})
</span>
</template> </template>
</label> </label>
<div <div

@ -1,7 +1,7 @@
<template> <template>
<label <label
v-if="!modelValue && !sessionId" v-if="!modelValue && !sessionId"
class="label text-2xl mb-2" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5"
> >
<MarkdownContent <MarkdownContent
v-if="field.title" v-if="field.title"

@ -1,8 +1,9 @@
<template> <template>
<div> <div>
<label <label
v-if="showFieldNames"
:for="isCodeSent ? 'one_time_code' : field.uuid" :for="isCodeSent ? 'one_time_code' : field.uuid"
class="label text-2xl" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
> >
<MarkdownContent <MarkdownContent
@ -10,10 +11,7 @@
:string="field.title" :string="field.title"
/> />
<template v-else> <template v-else>
{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }} {{ field.name || t('verified_phone_number') }}
<template v-if="!field.required">
({{ t('optional') }})
</template>
</template> </template>
</label> </label>
<div <div
@ -297,7 +295,7 @@ export default {
const data = await resp.json() const data = await resp.json()
if (resp.status === 422) { if (resp.status === 422) {
alert(this.t('number_phone_is_invalid').replace('{number}', this.fullInternationalPhoneValue)) alert(data.error || this.t('number_phone_is_invalid').replace('{number}', this.fullInternationalPhoneValue))
} else if (resp.status === 429) { } else if (resp.status === 429) {
alert(data.error) alert(data.error)
} }

@ -4,21 +4,22 @@
class="relative" class="relative"
> >
<div <div
class="flex justify-between items-center w-full" class="flex justify-between items-end w-full mb-3.5 md:mb-4"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
> >
<label <label
class="label text-2xl" v-if="showFieldNames"
class="label text-xl sm:text-2xl py-0"
> >
<MarkdownContent <MarkdownContent
v-if="field.title" v-if="field.title"
:string="field.title" :string="field.title"
/> />
<template v-else> <template v-else>
{{ showFieldNames && field.name ? field.name : t('signature') }} {{ field.name || t('signature') }}
</template> </template>
</label> </label>
<div class="space-x-2 flex"> <div class="space-x-2 flex flex-none">
<span <span
v-if="isTextSignature && format !== 'typed' && format !== 'upload'" v-if="isTextSignature && format !== 'typed' && format !== 'upload'"
class="tooltip" class="tooltip"
@ -39,6 +40,7 @@
<span <span
v-else-if="withTypedSignature && format !== 'typed' && format !== 'drawn' && format !== 'upload'" v-else-if="withTypedSignature && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
class="tooltip ml-2" class="tooltip ml-2"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('type_text')" :data-tip="t('type_text')"
> >
<a <a
@ -56,6 +58,7 @@
<span <span
v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'" v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'"
class="tooltip" class="tooltip"
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('take_photo')" :data-tip="t('take_photo')"
> >
<label <label

@ -3,15 +3,18 @@
v-if="showFieldNames && (field.name || field.title)" v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid" :for="field.uuid"
dir="auto" dir="auto"
class="label text-2xl" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
><MarkdownContent >
v-if="field.title" <MarkdownContent
:string="field.title" v-if="field.title"
/> :string="field.title"
/>
<template v-else>{{ field.name }}</template> <template v-else>{{ field.name }}</template>
<template v-if="!field.required"> <template v-if="!field.required">
({{ t('optional') }}) <span :class="{ 'hidden sm:inline': (field.title || field.name).length > 20 }">
({{ t('optional') }})
</span>
</template> </template>
</label> </label>
<div <div

@ -1,6 +1,6 @@
<template> <template>
<label <label
class="label text-2xl mb-2" class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5"
> >
<MarkdownContent <MarkdownContent
v-if="field.title" v-if="field.title"
@ -31,6 +31,15 @@
class="animate-spin h-10" class="animate-spin h-10"
/> />
</div> </div>
<div v-else-if="redirectUrl">
<a
:href="redirectUrl"
target="_blank"
class="white-button w-full"
>
{{ t('verify_id') }}
</a>
</div>
<div <div
ref="widgetContainer" ref="widgetContainer"
/> />
@ -87,6 +96,7 @@ export default {
return { return {
isCreatingCheckout: false, isCreatingCheckout: false,
isMathLoaded: false, isMathLoaded: false,
redirectUrl: '',
isLoading: false, isLoading: false,
eidEasyData: {} eidEasyData: {}
} }
@ -147,14 +157,26 @@ export default {
}).then(async (resp) => { }).then(async (resp) => {
this.eidEasyData = await resp.json() this.eidEasyData = await resp.json()
const eidEasyWidget = document.createElement('eideasy-widget') if (this.eidEasyData.available_methods[0] === 'itsme-qes-signature' &&
this.eidEasyData.available_methods.length === 1) {
const redirectUrl = new URL('https://id.eideasy.com/sign_contract_external')
for (const key in this.widgetSettings) { redirectUrl.searchParams.append('client_id', this.eidEasyData.client_id)
eidEasyWidget[key] = this.widgetSettings[key] redirectUrl.searchParams.append('doc_id', this.eidEasyData.doc_id)
} redirectUrl.searchParams.append('country', this.countryCode)
redirectUrl.searchParams.append('lang', this.locale)
this.redirectUrl = redirectUrl.toString()
} else {
const eidEasyWidget = document.createElement('eideasy-widget')
this.$refs.widgetContainer.innerHTML = '' for (const key in this.widgetSettings) {
this.$refs.widgetContainer.appendChild(eidEasyWidget) eidEasyWidget[key] = this.widgetSettings[key]
}
this.$refs.widgetContainer.innerHTML = ''
this.$refs.widgetContainer.appendChild(eidEasyWidget)
}
}) })
}, },
async submit () { async submit () {

@ -361,6 +361,7 @@ export default {
isMoved: false, isMoved: false,
renderDropdown: false, renderDropdown: false,
isNameFocus: false, isNameFocus: false,
isHeadingSelected: false,
textOverflowChars: 0, textOverflowChars: 0,
dragFrom: { x: 0, y: 0 } dragFrom: { x: 0, y: 0 }
} }
@ -377,7 +378,7 @@ export default {
} }
}, },
isValueInput () { isValueInput () {
return (this.field.type === 'heading' && this.isSelected) || this.isContenteditable || (this.inputMode && ['text', 'number', 'date'].includes(this.field.type)) return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable || (this.inputMode && ['text', 'number', 'date'].includes(this.field.type))
}, },
modalContainerEl () { modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container') return this.$el.getRootNode().querySelector('#docuseal_modal_container')
@ -479,13 +480,13 @@ export default {
methods: { methods: {
buildDefaultName: Field.methods.buildDefaultName, buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () { closeDropdown () {
document.activeElement.blur() this.$el.getRootNode().activeElement.blur()
}, },
maybeToggleDefaultValue () { maybeToggleDefaultValue () {
if (['text', 'number'].includes(this.field.type)) { if (['text', 'number'].includes(this.field.type)) {
this.isContenteditable = true this.isContenteditable = true
this.$nextTick(() => this.focusValueInput()) this.focusValueInput()
} else if (this.field.type === 'checkbox') { } else if (this.field.type === 'checkbox') {
this.field.readonly = !this.field.readonly this.field.readonly = !this.field.readonly
this.field.default_value === true ? delete this.field.default_value : this.field.default_value = true this.field.default_value === true ? delete this.field.default_value : this.field.default_value = true
@ -507,20 +508,28 @@ export default {
} }
}, },
focusValueInput (e) { focusValueInput (e) {
if (this.$refs.defaultValue !== document.activeElement) { this.$nextTick(() => {
this.$refs.defaultValue.focus() if (this.$refs.defaultValue && this.$refs.defaultValue !== document.activeElement) {
this.$refs.defaultValue.focus()
if (this.$refs.defaultValue.innerText.length && this.$refs.defaultValue !== e?.target) {
window.getSelection().collapse( if (this.$refs.defaultValue.innerText.length && this.$refs.defaultValue !== e?.target) {
this.$refs.defaultValue.firstChild, window.getSelection().collapse(
this.$refs.defaultValue.innerText.length this.$refs.defaultValue.firstChild,
) this.$refs.defaultValue.innerText.length
)
}
} }
} })
}, },
formatNumber (number, format) { formatNumber (number, format) {
if (format === 'comma') { if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number) return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'usd') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'gbp') {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'eur') {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'dot') { } else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number) return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') { } else if (format === 'space') {
@ -626,6 +635,7 @@ export default {
const text = this.$refs.defaultValue.innerText.trim() const text = this.$refs.defaultValue.innerText.trim()
this.isContenteditable = false this.isContenteditable = false
this.isHeadingSelected = false
if (text) { if (text) {
if (this.field.type === 'number') { if (this.field.type === 'number') {
@ -743,10 +753,6 @@ export default {
this.selectedAreaRef.value = this.area this.selectedAreaRef.value = this.area
if (this.field.type === 'heading') {
this.$nextTick(() => this.focusValueInput())
}
this.dragFrom = { x: rect.left - e.clientX, y: rect.top - e.clientY } this.dragFrom = { x: rect.left - e.clientX, y: rect.top - e.clientY }
this.$el.getRootNode().addEventListener('mousemove', this.mouseMove) this.$el.getRootNode().addEventListener('mousemove', this.mouseMove)
@ -781,6 +787,12 @@ export default {
this.save() this.save()
} }
if (this.field.type === 'heading') {
this.isHeadingSelected = !this.isMoved
this.focusValueInput()
}
this.isDragged = false this.isDragged = false
this.isMoved = false this.isMoved = false

@ -222,8 +222,8 @@
</div> </div>
<div <div
id="pages_container" id="pages_container"
class="w-full overflow-y-hidden overflow-x-hidden mt-0.5 pt-0.5" class="w-full overflow-x-hidden mt-0.5 pt-0.5"
:class="isMobile ? 'overflow-y-auto' : 'md:overflow-y-auto'" :class="isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto'"
> >
<div <div
ref="documents" ref="documents"
@ -366,6 +366,7 @@
:with-help="withHelp" :with-help="withHelp"
:default-submitters="defaultSubmitters" :default-submitters="defaultSubmitters"
:draw-field-type="drawFieldType" :draw-field-type="drawFieldType"
:with-fields-search="withFieldsSearch"
:default-fields="[...defaultRequiredFields, ...defaultFields]" :default-fields="[...defaultRequiredFields, ...defaultFields]"
:template="template" :template="template"
:default-required-fields="defaultRequiredFields" :default-required-fields="defaultRequiredFields"
@ -623,6 +624,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
withFieldsSearch: {
type: Boolean,
required: false,
default: null
},
withFieldsList: { withFieldsList: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -1320,7 +1326,11 @@ export default {
if (!this.fieldsDragFieldRef.value) { if (!this.fieldsDragFieldRef.value) {
if (['select', 'multiple', 'radio'].includes(field.type)) { if (['select', 'multiple', 'radio'].includes(field.type)) {
field.options = [{ value: '', uuid: v4() }] if (this.dragField?.options?.length) {
field.options = this.dragField.options.map(option => ({ value: option, uuid: v4() }))
} else {
field.options = [{ value: '', uuid: v4() }]
}
} }
if (['stamp', 'heading'].includes(field.type)) { if (['stamp', 'heading'].includes(field.type)) {
@ -1391,7 +1401,6 @@ export default {
const lastArea = field.areas[field.areas.length - 1] const lastArea = field.areas[field.areas.length - 1]
if (lastArea) { if (lastArea) {
fieldArea.x -= lastArea.w / 2
fieldArea.w = lastArea.w fieldArea.w = lastArea.w
fieldArea.h = lastArea.h fieldArea.h = lastArea.h
} }
@ -1423,6 +1432,8 @@ export default {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid) const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const areaRef = documentRef.pageRefs[area.page].areaRefs.find((ref) => ref.area === this.selectedAreaRef.value) const areaRef = documentRef.pageRefs[area.page].areaRefs.find((ref) => ref.area === this.selectedAreaRef.value)
areaRef.isHeadingSelected = true
areaRef.focusValueInput() areaRef.focusValueInput()
}) })
} }
@ -1510,29 +1521,29 @@ export default {
onDocumentRemove (item) { onDocumentRemove (item) {
if (window.confirm(this.t('are_you_sure_'))) { if (window.confirm(this.t('are_you_sure_'))) {
this.template.schema.splice(this.template.schema.indexOf(item), 1) this.template.schema.splice(this.template.schema.indexOf(item), 1)
}
const removedFieldUuids = [] const removedFieldUuids = []
this.template.fields.forEach((field) => { this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => { [...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) { if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1) field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid) removedFieldUuids.push(field.uuid)
} }
})
}) })
})
this.template.fields = this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length) this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
this.save() this.save()
}
}, },
onDocumentReplace (data) { onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data const { replaceSchemaItem, schema, documents } = data
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, schema[0]) this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...replaceSchemaItem, ...schema[0] })
this.template.documents.push(...documents) this.template.documents.push(...documents)
if (data.fields) { if (data.fields) {

@ -177,7 +177,7 @@ export default {
fields () { fields () {
if (this.item.submitter_uuid) { if (this.item.submitter_uuid) {
return this.template.fields.reduce((acc, f) => { return this.template.fields.reduce((acc, f) => {
if (f !== this.item && f.submitter_uuid === this.item.submitter_uuid) { if (f !== this.item) {
acc.push(f) acc.push(f)
} }

@ -192,14 +192,14 @@
class="w-full input input-primary input-xs text-sm bg-transparent" class="w-full input input-primary input-xs text-sm bg-transparent"
:placeholder="`${t('option')} ${index + 1}`" :placeholder="`${t('option')} ${index + 1}`"
type="text" type="text"
:readonly="!editable" :readonly="!editable || defaultField"
required required
dir="auto" dir="auto"
@focus="maybeFocusOnOptionArea(option)" @focus="maybeFocusOnOptionArea(option)"
@blur="save" @blur="save"
> >
<button <button
v-if="editable" v-if="editable && !defaultField"
class="text-sm w-3.5" class="text-sm w-3.5"
tabindex="-1" tabindex="-1"
@click="removeOption(option)" @click="removeOption(option)"
@ -208,11 +208,11 @@
</button> </button>
</div> </div>
<div <div
v-if="field.options && !editable" v-if="field.options && (!editable || defaultField)"
class="pb-1" class="pb-1"
/> />
<button <button
v-else-if="field.options && editable" v-else-if="field.options && editable && !defaultField"
class="text-center text-sm w-full pb-1" class="text-center text-sm w-full pb-1"
@click="addOption" @click="addOption"
> >
@ -392,7 +392,7 @@ export default {
return this.sortedAreas[0] && this.$emit('scroll-to', this.sortedAreas[0]) return this.sortedAreas[0] && this.$emit('scroll-to', this.sortedAreas[0])
}, },
closeDropdown () { closeDropdown () {
document.activeElement.blur() this.$el.getRootNode().activeElement.blur()
}, },
addOption () { addOption () {
this.field.options.push({ value: '', uuid: v4() }) this.field.options.push({ value: '', uuid: v4() })

@ -26,6 +26,33 @@
{{ t('format') }} {{ t('format') }}
</label> </label>
</div> </div>
<div
v-if="field.type === 'verification'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('method')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences ||= {}, field.preferences.method = $event.target.value, save()]"
>
<option
v-for="method in ['QeS', 'AeS']"
:key="method"
:value="method.toLowerCase()"
:selected="method.toLowerCase() === field.preferences?.method || (method === 'QeS' && !field.preferences?.method)"
>
{{ method }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('method') }}
</label>
</div>
<div <div
v-if="['number', 'cells'].includes(field.type)" v-if="['number', 'cells'].includes(field.type)"
class="py-1.5 px-1 relative" class="py-1.5 px-1 relative"
@ -485,6 +512,9 @@ export default {
numberFormats() { numberFormats() {
return [ return [
'none', 'none',
'usd',
'eur',
'gbp',
'comma', 'comma',
'dot', 'dot',
'space' 'space'
@ -565,6 +595,27 @@ export default {
formatNumber(number, format) { formatNumber(number, format) {
if (format === 'comma') { if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number) return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'usd') {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(number)
} else if (format === 'gbp') {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(number)
} else if (format === 'eur') {
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}).format(number)
} else if (format === 'dot') { } else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number) return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') { } else if (format === 'space') {
@ -604,4 +655,4 @@ export default {
} }
} }
} }
</script> </script>

@ -143,6 +143,7 @@
<li <li
v-for="(submitter, index) in submitters" v-for="(submitter, index) in submitters"
:key="submitter.uuid" :key="submitter.uuid"
class="w-full"
> >
<a <a
href="#" href="#"
@ -191,7 +192,10 @@
</div> </div>
</a> </a>
</li> </li>
<li v-if="submitters.length < names.length && editable && allowAddNew"> <li
v-if="submitters.length < names.length && editable && allowAddNew"
class="w-full"
>
<a <a
href="#" href="#"
class="flex px-2" class="flex px-2"
@ -215,6 +219,14 @@ import { IconUserPlus, IconTrashX, IconPlus, IconChevronUp, IconChevronDown } fr
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import { v4 } from 'uuid' import { v4 } from 'uuid'
function getOrdinalSuffix (num) {
if (num % 10 === 1 && num % 100 !== 11) return 'st'
if (num % 10 === 2 && num % 100 !== 12) return 'nd'
if (num % 10 === 3 && num % 100 !== 13) return 'rd'
return 'th'
}
export default { export default {
name: 'FieldSubmitter', name: 'FieldSubmitter',
components: { components: {
@ -288,6 +300,14 @@ export default {
] ]
}, },
names () { names () {
const generatedNames = []
for (let i = 21; i < 101; i++) {
const suffix = getOrdinalSuffix(i)
generatedNames.push(`${i}${suffix} ${this.t('party')}`)
}
return [ return [
this.t('first_party'), this.t('first_party'),
this.t('second_party'), this.t('second_party'),
@ -308,7 +328,8 @@ export default {
this.t('seventeenth_party'), this.t('seventeenth_party'),
this.t('eighteenth_party'), this.t('eighteenth_party'),
this.t('nineteenth_party'), this.t('nineteenth_party'),
this.t('twentieth_party') this.t('twentieth_party'),
...generatedNames
] ]
}, },
lastPartyIndex () { lastPartyIndex () {
@ -362,7 +383,7 @@ export default {
this.$emit('new-submitter', newSubmitter) this.$emit('new-submitter', newSubmitter)
}, },
closeDropdown () { closeDropdown () {
document.activeElement.blur() this.$el.getRootNode().activeElement.blur()
} }
} }
} }

@ -170,7 +170,7 @@ export default {
}, },
methods: { methods: {
closeDropdown () { closeDropdown () {
document.activeElement.blur() this.$el.getRootNode().activeElement.blur()
} }
} }
} }

@ -5,7 +5,7 @@
class="roles-dropdown w-full rounded-lg" class="roles-dropdown w-full rounded-lg"
:style="withStickySubmitters ? { backgroundColor } : {}" :style="withStickySubmitters ? { backgroundColor } : {}"
:submitters="submitters" :submitters="submitters"
:menu-style="{ backgroundColor: ['', null, 'transparent'].includes(backgroundColor) ? 'white' : backgroundColor }" :menu-style="{ overflow: 'auto', display: 'flex', flexDirection: 'row', maxHeight: 'calc(100vh - 120px)', backgroundColor: ['', null, 'transparent'].includes(backgroundColor) ? 'white' : backgroundColor }"
:editable="editable && !defaultSubmitters.length" :editable="editable && !defaultSubmitters.length"
@new-submitter="save" @new-submitter="save"
@remove="removeSubmitter" @remove="removeSubmitter"
@ -229,6 +229,11 @@ export default {
type: Array, type: Array,
required: true required: true
}, },
withFieldsSearch: {
type: Boolean,
required: false,
default: null
},
template: { template: {
type: Object, type: Object,
required: true required: true
@ -297,7 +302,11 @@ export default {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
isShowFieldSearch () { isShowFieldSearch () {
return this.submitterDefaultFields.length > 15 if (this.withFieldsSearch === false) {
return false
} else {
return this.submitterDefaultFields.length > 15
}
}, },
defaultFieldsIndex () { defaultFieldsIndex () {
return this.defaultFields.reduce((acc, field) => { return this.defaultFields.reduce((acc, field) => {
@ -327,7 +336,7 @@ export default {
}, },
filteredSubmitterDefaultFields () { filteredSubmitterDefaultFields () {
if (this.defaultFieldsSearch) { if (this.defaultFieldsSearch) {
return this.submitterDefaultFields.filter((f) => f.name.toLowerCase().includes(this.defaultFieldsSearch.toLowerCase())) return this.submitterDefaultFields.filter((f) => (f.title || f.name).toLowerCase().includes(this.defaultFieldsSearch.toLowerCase()))
} else { } else {
return this.submitterDefaultFields return this.submitterDefaultFields
} }

@ -1,4 +1,6 @@
const en = { const en = {
party: 'Party',
method: 'Method',
reorder_fields: 'Reorder fields', reorder_fields: 'Reorder fields',
verify_id: 'Verify ID', verify_id: 'Verify ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtain qualified electronic signature (QeS) with the trusted provider. Click to learn more.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtain qualified electronic signature (QeS) with the trusted provider. Click to learn more.',
@ -160,6 +162,8 @@ const en = {
} }
const es = { const es = {
party: 'Parte',
method: 'Método',
reorder_fields: 'Reordenar campos', reorder_fields: 'Reordenar campos',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenga una firma electrónica cualificada (QeS) con el proveedor de confianza. Haga clic para obtener más información.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenga una firma electrónica cualificada (QeS) con el proveedor de confianza. Haga clic para obtener más información.',
@ -321,6 +325,8 @@ const es = {
} }
const it = { const it = {
party: 'Parte',
method: 'Metodo',
reorder_fields: 'Riordina i campi', reorder_fields: 'Riordina i campi',
verify_id: 'Verifica ID', verify_id: 'Verifica ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Ottieni una firma elettronica qualificata (QeS) con il fornitore di fiducia. Clicca per saperne di più.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Ottieni una firma elettronica qualificata (QeS) con il fornitore di fiducia. Clicca per saperne di più.',
@ -482,6 +488,8 @@ const it = {
} }
const pt = { const pt = {
party: 'Parte',
method: 'Método',
reorder_fields: 'Reorganizar campos', reorder_fields: 'Reorganizar campos',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenha a assinatura eletrônica qualificada (QeS) com o provedor confiável. Clique para saber mais.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenha a assinatura eletrônica qualificada (QeS) com o provedor confiável. Clique para saber mais.',
@ -643,6 +651,8 @@ const pt = {
} }
const fr = { const fr = {
party: 'Partie',
method: 'Méthode',
reorder_fields: 'Réorganiser les champs', reorder_fields: 'Réorganiser les champs',
verify_id: "Vérifier l'ID", verify_id: "Vérifier l'ID",
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenez une signature électronique qualifiée (QeS) avec le fournisseur de confiance. Cliquez pour en savoir plus.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenez une signature électronique qualifiée (QeS) avec le fournisseur de confiance. Cliquez pour en savoir plus.',
@ -804,6 +814,8 @@ const fr = {
} }
const de = { const de = {
party: 'Partei',
method: 'Verfahren',
reorder_fields: 'Felder neu anordnen', reorder_fields: 'Felder neu anordnen',
verify_id: 'ID überprüfen', verify_id: 'ID überprüfen',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Erhalten Sie eine qualifizierte elektronische Signatur (QeS) beim vertrauenswürdigen Anbieter. Klicken Sie hier, um mehr zu erfahren.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Erhalten Sie eine qualifizierte elektronische Signatur (QeS) beim vertrauenswürdigen Anbieter. Klicken Sie hier, um mehr zu erfahren.',

@ -5,8 +5,6 @@ class SendFormCompletedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 20 MAX_ATTEMPTS = 20
def perform(params = {}) def perform(params = {})
@ -21,19 +19,8 @@ class SendFormCompletedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = begin resp = SendWebhookRequest.call(webhook_url, event_type: 'form.completed',
Faraday.post(webhook_url.url, data: Submitters::SerializeForWebhook.call(submitter))
{
event_type: 'form.completed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT,
**webhook_url.secret.to_h)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))

@ -5,8 +5,6 @@ class SendFormDeclinedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
@ -19,19 +17,8 @@ class SendFormDeclinedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = begin resp = SendWebhookRequest.call(webhook_url, event_type: 'form.declined',
Faraday.post(webhook_url.url, data: Submitters::SerializeForWebhook.call(submitter))
{
event_type: 'form.declined',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
**webhook_url.secret.to_h,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))

@ -5,8 +5,6 @@ class SendFormStartedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
@ -19,19 +17,8 @@ class SendFormStartedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = begin resp = SendWebhookRequest.call(webhook_url, event_type: 'form.started',
Faraday.post(webhook_url.url, data: Submitters::SerializeForWebhook.call(submitter))
{
event_type: 'form.started',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
**webhook_url.secret.to_h,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))

@ -5,8 +5,6 @@ class SendFormViewedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
@ -19,19 +17,8 @@ class SendFormViewedWebhookRequestJob
ActiveStorage::Current.url_options = Docuseal.default_url_options ActiveStorage::Current.url_options = Docuseal.default_url_options
resp = begin resp = SendWebhookRequest.call(webhook_url, event_type: 'form.viewed',
Faraday.post(webhook_url.url, data: Submitters::SerializeForWebhook.call(submitter))
{
event_type: 'form.viewed',
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
**webhook_url.secret.to_h,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))

@ -5,8 +5,6 @@ class SendSubmissionArchivedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
@ -17,19 +15,8 @@ class SendSubmissionArchivedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.archived') return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.archived')
resp = begin resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.archived',
Faraday.post(webhook_url.url, data: submission.as_json(only: %i[id archived_at]))
{
event_type: 'submission.archived',
timestamp: Time.current,
data: submission.as_json(only: %i[id archived_at])
}.to_json,
**webhook_url.secret.to_h,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))

@ -5,8 +5,6 @@ class SendSubmissionCompletedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
@ -17,19 +15,8 @@ class SendSubmissionCompletedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.completed') return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.completed')
resp = begin resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.completed',
Faraday.post(webhook_url.url, data: Submissions::SerializeForApi.call(submission))
{
event_type: 'submission.completed',
timestamp: Time.current,
data: Submissions::SerializeForApi.call(submission)
}.to_json,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT,
**webhook_url.secret.to_h)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))

@ -5,8 +5,6 @@ class SendSubmissionCreatedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
@ -17,19 +15,8 @@ class SendSubmissionCreatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.created') return if webhook_url.url.blank? || webhook_url.events.exclude?('submission.created')
resp = begin resp = SendWebhookRequest.call(webhook_url, event_type: 'submission.created',
Faraday.post(webhook_url.url, data: Submissions::SerializeForApi.call(submission))
{
event_type: 'submission.created',
timestamp: Time.current,
data: Submissions::SerializeForApi.call(submission)
}.to_json,
**webhook_url.secret.to_h,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))

@ -8,6 +8,12 @@ class SendSubmitterInvitationEmailJob
return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true) return if submitter.submission.source == 'invite' && !Accounts.can_send_emails?(submitter.account, on_events: true)
unless Accounts.can_send_invitation_emails?(submitter.account)
Rollbar.warning("Skip email: #{submitter.account.id}") if defined?(Rollbar)
return
end
mail = SubmitterMailer.invitation_email(submitter) mail = SubmitterMailer.invitation_email(submitter)
Submitters::ValidateSending.call(submitter, mail) Submitters::ValidateSending.call(submitter, mail)
@ -17,6 +23,6 @@ class SendSubmitterInvitationEmailJob
SubmissionEvent.create!(submitter:, event_type: 'send_email') SubmissionEvent.create!(submitter:, event_type: 'send_email')
submitter.sent_at ||= Time.current submitter.sent_at ||= Time.current
submitter.save submitter.save!
end end
end end

@ -5,8 +5,6 @@ class SendTemplateCreatedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
@ -17,19 +15,8 @@ class SendTemplateCreatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.created') return if webhook_url.url.blank? || webhook_url.events.exclude?('template.created')
resp = begin resp = SendWebhookRequest.call(webhook_url, event_type: 'template.created',
Faraday.post(webhook_url.url, data: Templates::SerializeForApi.call(template))
{
event_type: 'template.created',
timestamp: Time.current,
data: Templates::SerializeForApi.call(template)
}.to_json,
**webhook_url.secret.to_h,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))

@ -5,8 +5,6 @@ class SendTemplateUpdatedWebhookRequestJob
sidekiq_options queue: :webhooks sidekiq_options queue: :webhooks
USER_AGENT = 'DocuSeal.com Webhook'
MAX_ATTEMPTS = 10 MAX_ATTEMPTS = 10
def perform(params = {}) def perform(params = {})
@ -17,19 +15,8 @@ class SendTemplateUpdatedWebhookRequestJob
return if webhook_url.url.blank? || webhook_url.events.exclude?('template.updated') return if webhook_url.url.blank? || webhook_url.events.exclude?('template.updated')
resp = begin resp = SendWebhookRequest.call(webhook_url, event_type: 'template.updated',
Faraday.post(webhook_url.url, data: Templates::SerializeForApi.call(template))
{
event_type: 'template.updated',
timestamp: Time.current,
data: Templates::SerializeForApi.call(template)
}.to_json,
**webhook_url.secret.to_h,
'Content-Type' => 'application/json',
'User-Agent' => USER_AGENT)
rescue Faraday::Error
nil
end
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS && if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan)) (!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))

@ -14,8 +14,20 @@ class SubmitterMailer < ApplicationMailer
@email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid']) @email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid'])
end end
@body = @email_message&.body.presence || @submitter.template.preferences['request_email_body'].presence template_submitters_index =
@subject = @email_message&.subject.presence || @submitter.template.preferences['request_email_subject'].presence if @email_message.blank?
build_submitter_preferences_index(@submitter)
else
{}
end
@body = @email_message&.body.presence ||
template_submitters_index.dig(@submitter.uuid, 'request_email_body').presence ||
@submitter.template.preferences['request_email_body'].presence
@subject = @email_message&.subject.presence ||
template_submitters_index.dig(@submitter.uuid, 'request_email_subject').presence ||
@submitter.template.preferences['request_email_subject'].presence
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) @email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
@ -24,14 +36,7 @@ class SubmitterMailer < ApplicationMailer
reply_to = build_submitter_reply_to(@submitter) reply_to = build_submitter_reply_to(@submitter)
I18n.with_locale(@current_account.locale) do I18n.with_locale(@current_account.locale) do
subject = subject = build_invite_subject(@subject, @email_config, submitter)
if @email_config || @subject
ReplaceEmailVariables.call(@subject || @email_config.value['subject'], submitter:)
elsif @submitter.with_signature_fields?
I18n.t(:you_are_invited_to_sign_a_document)
else
I18n.t(:you_are_invited_to_submit_a_form)
end
mail( mail(
to: @submitter.friendly_name, to: @submitter.friendly_name,
@ -196,6 +201,20 @@ class SubmitterMailer < ApplicationMailer
user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name user.role == 'integration' ? user.friendly_name.sub(/\+\w+@/, '@') : user.friendly_name
end end
def build_invite_subject(subject, email_config, submitter)
if email_config || subject
ReplaceEmailVariables.call(subject || email_config.value['subject'], submitter:)
elsif submitter.with_signature_fields?
I18n.t(:you_are_invited_to_sign_a_document)
else
I18n.t(:you_are_invited_to_submit_a_form)
end
end
def build_submitter_preferences_index(submitter)
submitter.template.preferences['submitters'].to_a.index_by { |e| e['uuid'] }
end
def add_attachments_with_size_limit(submitter, storage_attachments, current_size, filename_format = nil) def add_attachments_with_size_limit(submitter, storage_attachments, current_size, filename_format = nil)
total_size = current_size total_size = current_size

@ -153,6 +153,7 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<%= render 'compliances' %>
<%= render 'integrations' %> <%= render 'integrations' %>
<% if can?(:manage, current_account) && Docuseal.multitenant? && true_user == current_user %> <% if can?(:manage, current_account) && Docuseal.multitenant? && true_user == current_user %>
<div class="px-1 mt-8"> <div class="px-1 mt-8">

@ -173,7 +173,7 @@
<%= t('document_download_filename_format') %> <%= t('document_download_filename_format') %>
</span> </span>
<div class="mt-3"> <div class="mt-3">
<%= f.select :value, [["#{I18n.t('document_name')}.pdf", '{document.name}'], ["#{I18n.t('document_name')} - name@domain.com.pdf", '{document.name} - {submission.submitters}'], ["#{I18n.t('document_name')} - name@domain.com - #{I18n.l(Time.current.beginning_of_year.in_time_zone(current_account.timezone), format: :short)}.pdf", '{document.name} - {submission.submitters} - {submission.completed_at}']], {}, class: 'base-select', onchange: 'this.form.requestSubmit()' %> <%= f.select :value, [["#{I18n.t('document_name')}.pdf", '{document.name}'], ["#{I18n.t('document_name')} - #{I18n.t(:signed)}.pdf", '{document.name} - {submission.status}'], ["#{I18n.t('document_name')} - name@domain.com.pdf", '{document.name} - {submission.submitters}'], ["#{I18n.t('document_name')} - name@domain.com - #{I18n.l(Time.current.beginning_of_year.in_time_zone(current_account.timezone), format: :short)}.pdf", '{document.name} - {submission.submitters} - {submission.completed_at}']], {}, class: 'base-select', onchange: 'this.form.requestSubmit()' %>
</div> </div>
</div> </div>
<% end %> <% end %>

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M7 16h-4l3.47 -4.66a2 2 0 1 0 -3.47 -1.54"></path>
<path d="M10 16v-8h4"></path> <path d="M10 12l3 0"></path>
<path d="M17 16v-6a2 2 0 0 1 4 0v6"></path>
<path d="M17 13l4 0"></path>
</svg>

After

Width:  |  Height:  |  Size: 426 B

@ -0,0 +1,5 @@
<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="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
</svg>

After

Width:  |  Height:  |  Size: 397 B

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M8 13v-8.5a1.5 1.5 0 0 1 3 0v7.5"></path>
<path d="M11 11.5v-2a1.5 1.5 0 0 1 3 0v2.5"></path>
<path d="M14 10.5a1.5 1.5 0 0 1 3 0v1.5"></path>
<path d="M17 11.5a1.5 1.5 0 0 1 3 0v4.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7l-.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47"></path>
<path d="M5 3l-1 -1"></path>
<path d="M4 7h-1"></path>
<path d="M14 3l1 -1"></path>
<path d="M15 6h1"></path>
</svg>

After

Width:  |  Height:  |  Size: 711 B

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M4 12h.01"></path>
<path d="M4 6h.01"></path>
<path d="M4 18h.01"></path>
<path d="M8 18h2"></path>
<path d="M8 12h2"></path>
<path d="M8 6h2"></path>
<path d="M14 6h6"></path>
<path d="M14 12h6"></path>
<path d="M14 18h6"></path>
</svg>

After

Width:  |  Height:  |  Size: 484 B

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M3 9l9 6l9 -6l-9 -6l-9 6"></path>
<path d="M21 9v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10"></path>
<path d="M3 19l6 -6"></path>
<path d="M15 13l6 6"></path>
</svg>

After

Width:  |  Height:  |  Size: 401 B

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M5 4h4l2 5l-2.5 1.5a11 11 0 0 0 5 5l1.5 -2.5l5 2v4a2 2 0 0 1 -2 2a16 16 0 0 1 -15 -15a2 2 0 0 1 2 -2"></path>
<path d="M15 6l2 2l4 -4"></path>
</svg>

After

Width:  |  Height:  |  Size: 382 B

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M7 4v16l13 -8z"></path>
</svg>

After

Width:  |  Height:  |  Size: 261 B

@ -2,6 +2,9 @@
<html data-theme="docuseal" lang="<%= I18n.locale %>"> <html data-theme="docuseal" lang="<%= I18n.locale %>">
<head> <head>
<%= render 'layouts/head_tags' %> <%= render 'layouts/head_tags' %>
<% if Docuseal.enable_pwa? %>
<link rel="manifest" href="/manifest.json">
<% end %>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>

@ -0,0 +1,25 @@
{
"name": "<%= Docuseal.product_name %>",
"short_name": "<%= Docuseal.product_name %>",
"id": "/",
"icons": [
{
"src": "/logo.svg",
"type": "image/svg+xml",
"sizes": "any"
},
{
"src": "/apple-touch-icon.png",
"type": "image/png",
"sizes": "192x192"
}
],
"start_url": "/",
"display": "standalone",
"scope": "/",
"orientation": "natural",
"description": "<%= Docuseal.product_name %> is an open source platform that provides secure and efficient digital document signing and processing.",
"categories": ["productivity", "utilities"],
"theme_color": "#FAF7F4",
"background_color": "#FAF7F4"
}

@ -2,7 +2,7 @@
<div class="space-y-6 mx-auto"> <div class="space-y-6 mx-auto">
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<%= render 'start_form/docuseal_logo' %> <%= render 'start_form/banner' %>
</div> </div>
<div class="text-center text-4xl font-bold"> <div class="text-center text-4xl font-bold">
<%= t('email_has_been_sent') %> <%= t('email_has_been_sent') %>

@ -2,5 +2,9 @@
--- ---
</p> </p>
<p> <p>
<%= t('sent_using_product_name_free_document_signing_html', product_url: "#{Docuseal::PRODUCT_EMAIL_URL}/start", product_name: Docuseal.product_name) %> <% if @current_account&.testing? %>
<%= t('sent_using_product_name_in_testing_mode_html', product_url: "#{Docuseal::PRODUCT_EMAIL_URL}/start", product_name: Docuseal.product_name) %>
<% else %>
<%= t('sent_using_product_name_free_document_signing_html', product_url: "#{Docuseal::PRODUCT_EMAIL_URL}/start", product_name: Docuseal.product_name) %>
<% end %>
</p> </p>

@ -1,3 +1,4 @@
<%= render 'shared/navbar_warning' %>
<div class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between"> <div class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between">
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2"> <a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2">

@ -0,0 +1,49 @@
<% event_colors = %w[bg-red-200 bg-sky-200 bg-emerald-200 bg-yellow-200 bg-purple-200 bg-pink-200 bg-cyan-200 bg-orange-200 bg-lime-200 bg-indigo-200] %>
<% submitters_uuids = (@submission.template_submitters || @submission.template.submitters).pluck('uuid') %>
<%= render 'shared/turbo_modal_large', title: t('event_log') do %>
<div class="pl-8 pr-4 py-4">
<ol class="relative border-s border-base-300 space-y-6">
<li class="ml-7">
<span class="absolute flex items-center justify-center w-7 h-7 rounded-full -start-3.5 ring-8 ring-base-100 text-base-content bg-gray-200">
<%= svg_icon('file_text', class: 'w-4 h-4') %>
</span>
<p class="text-sm leading-none text-base-content/60 pt-1.5">
<%= l(@submission.created_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
</p>
<p class="text-base-content/80 mt-1">
<% if @submission.source == 'invite' %>
<%= t('submission_created_by_email_html', email: @submission.created_by_user.email) %>
<% elsif @submission.created_by_user %>
<%= t('submission_created_by_email_via_source_html', email: @submission.created_by_user.email, source: t("submission_sources.#{@submission.source}")) %>
<% else %>
<%= t('submission_created_via_source_html', source: t("submission_sources.#{@submission.source}")) %>
<% end %>
</p>
</li>
<% @submission.submission_events.order(:event_timestamp).each do |event| %>
<% submitter = @submission.submitters.find { |e| e.id == event.submitter_id } %>
<% bg_class = event_colors[submitters_uuids.index(submitter.uuid) % event_colors.length] %>
<% submitter_name = event.event_type.include?('sms') || event.event_type.include?('phone') ? (event.data['phone'] || submitter.phone) : (submitter.name || submitter.email || submitter.phone) %>
<li class="ml-7">
<span class="absolute flex items-center justify-center w-7 h-7 rounded-full -start-3.5 ring-8 ring-base-100 text-base-content <%= bg_class %>">
<%= svg_icon(SubmissionEventsController::SUBMISSION_EVENT_ICONS.fetch(event.event_type, 'circle_dot'), class: 'w-4 h-4') %>
</span>
<p class="text-sm leading-none text-base-content/60 pt-1.5">
<%= l(event.event_timestamp.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %>
</p>
<p class="text-base-content/80 mt-1">
<% if event.event_type == 'complete_verification' %>
<%= t('submission_event_names.complete_verification_by_html', provider: event.data['method'], submitter_name:) %>
<% elsif event.event_type == 'invite_party' && (invited_submitter = @submission.submitters.find { |e| e.uuid == event.data['uuid'] }) && (name = @submission.template_submitters.find { |e| e['uuid'] == event.data['uuid'] }&.dig('name')) %>
<%= t('submission_event_names.invite_party_by_html', invited_submitter_name: [invited_submitter.name || invited_submitter.email || invited_submitter.phone, name].join(' '), submitter_name:) %>
<% elsif event.event_type.include?('send_') %>
<%= t("submission_event_names.#{event.event_type}_to_html", submitter_name:) %>
<% else %>
<%= t("submission_event_names.#{event.event_type}_by_html", submitter_name:) %>
<% end %>
</p>
</li>
<% end %>
</ol>
</div>
<% end %>

@ -1,3 +1,4 @@
<% submitter_preferences_index = template.preferences['submitters'].to_a.index_by { |e| e['uuid'] } %>
<div class="form-control"> <div class="form-control">
<% can_send_emails = Accounts.can_send_emails?(current_account) %> <% can_send_emails = Accounts.can_send_emails?(current_account) %>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@ -36,10 +37,18 @@
<% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %> <% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) %>
<div id="message_field" class="card card-compact bg-base-300/40 hidden"> <div id="message_field" class="card card-compact bg-base-300/40 hidden">
<div class="card-body"> <div class="card-body">
<div class="form-control space-y-2"> <%= tag.input id: toggle_uuid = SecureRandom.uuid, value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: local_assigns[:message_per_submitter] != false && template.preferences['submitters'].to_a.size > 1 %>
<div class="peer-checked:hidden form-control space-y-2">
<div class="form-control"> <div class="form-control">
<%= f.label :subject, t('subject'), class: 'label' %> <div class="flex justify-between">
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || template.preferences['request_email_subject'].presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %> <%= f.label :subject, t('subject'), class: 'label' %>
<% if template.submitters.size > 1 && template.submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
<label for="<%= toggle_uuid %>" class="label underline">
<%= t('edit_per_party') %>
</label>
<% end %>
</div>
<%= f.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_subject').presence || template.preferences['request_email_subject'].presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
</div> </div>
<div class="form-control"> <div class="form-control">
<div class="flex items-center"> <div class="flex items-center">
@ -49,7 +58,7 @@
</span> </span>
</div> </div>
<autoresize-textarea> <autoresize-textarea>
<%= f.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || template.preferences['request_email_body'].presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %> <%= f.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(local_assigns[:submitter]&.uuid, 'request_email_body').presence || template.preferences['request_email_body'].presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
</autoresize-textarea> </autoresize-textarea>
<% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %> <% unless local_assigns.fetch(:disable_save_as_default_template_option, false) %>
<label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer"> <label for="<%= uuid = SecureRandom.uuid %>" class="flex items-center cursor-pointer">
@ -60,5 +69,44 @@
</div> </div>
<%= render 'submissions/message_fields' %> <%= render 'submissions/message_fields' %>
</div> </div>
<% if template.submitters.size > 1 && template.submitters.size < 5 && local_assigns[:message_per_submitter] != false %>
<div class="hidden peer-checked:block form-control space-y-2">
<% uuid = SecureRandom.uuid %>
<% options = template.submitters.map { |e| [e['name'], "request_email_#{uuid}_#{e['uuid']}"] } %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
<ul class="tabs w-full flex flex-nowrap">
<% options.each_with_index do |(label, val), index| %>
<div class="w-full">
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active !bg-transparent' %>
</div>
<% end %>
</ul>
</toggle-visible>
<% template.submitters.each_with_index do |submitter, index| %>
<%= fields_for :submitter_preferences, nil, index: submitter['uuid'] do |ff| %>
<div id="request_email_<%= uuid %>_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
<div class="form-control">
<div class="flex justify-between">
<%= ff.label :subject, t('subject'), class: 'label' %>
</div>
<%= ff.text_field :subject, value: local_assigns[:submitter_email_message]&.subject.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_subject').presence || template.preferences['request_email_subject'].presence || config.value['subject'], required: true, class: '!text-sm base-input w-full', dir: 'auto' %>
</div>
<div class="form-control">
<div class="flex items-center">
<%= ff.label :message, t('body'), class: 'label' %>
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
<autoresize-textarea>
<%= ff.text_area :body, value: local_assigns[:submitter_email_message]&.body.presence || submitter_preferences_index.dig(submitter['uuid'], 'request_email_body').presence || template.preferences['request_email_body'].presence || config.value['body'], required: true, class: 'base-textarea w-full', rows: 10, dir: 'auto' %>
</autoresize-textarea>
</div>
</div>
<% end %>
<% end %>
</div>
<% end %>
</div> </div>
</div> </div>

@ -1,6 +1,7 @@
<% align = field.dig('preferences', 'align') %> <% align = field.dig('preferences', 'align') %>
<% color = field.dig('preferences', 'color') %> <% color = field.dig('preferences', 'color') %>
<field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-serif' if field.dig('preferences', 'font') == 'Times' %> <%= align == 'right' ? 'justify-end' : (align == 'center' ? 'justify-center' : '') %>" style="<%= "color: #{color}; " if color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; <%= "font-size: clamp(4pt, 1.6vw, #{field['preferences']['font_size'].to_i * 1.23}pt); line-height: `clamp(6pt, 2.0vw, #{(field['preferences']['font_size'].to_i * 1.23) + 3}pt)`" if field.dig('preferences', 'font_size') %>"> <% font = field.dig('preferences', 'font') %>
<field-value dir="auto" class="flex absolute text-[1.6vw] lg:text-base <%= 'font-mono' if font == 'Courier' %> <%= 'font-serif' if font == 'Times' %> <%= align == 'right' ? 'justify-end' : (align == 'center' ? 'justify-center' : '') %>" style="<%= "color: #{color}; " if color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; <%= "font-size: clamp(4pt, 1.6vw, #{field['preferences']['font_size'].to_i * 1.23}pt); line-height: `clamp(6pt, 2.0vw, #{(field['preferences']['font_size'].to_i * 1.23) + 3}pt)`" if field.dig('preferences', 'font_size') %>">
<% if field['type'] == 'signature' %> <% if field['type'] == 'signature' %>
<div class="flex flex-col justify-between h-full overflow-hidden"> <div class="flex flex-col justify-between h-full overflow-hidden">
<div class="flex-grow flex overflow-hidden" style="min-height: 50%"> <div class="flex-grow flex overflow-hidden" style="min-height: 50%">

@ -19,6 +19,11 @@
<%= svg_icon('external_link', class: 'w-6 h-6') %> <%= svg_icon('external_link', class: 'w-6 h-6') %>
<span class="hidden md:inline"><%= t('audit_log') %></span> <span class="hidden md:inline"><%= t('audit_log') %></span>
</a> </a>
<% else %>
<%= link_to submission_events_path(@submission), class: 'white-button', data: { turbo_frame: :modal } do %>
<%= svg_icon('logs', class: 'w-6 h-6') %>
<span class="hidden md:block"><%= t('event_log') %></span>
<% end %>
<% end %> <% end %>
<% if last_submitter %> <% if last_submitter %>
<% if is_all_completed || !is_combined_enabled %> <% if is_all_completed || !is_combined_enabled %>
@ -118,7 +123,7 @@
<div class="group border border-base-300 rounded-md px-2 py-1 mb-1"> <div class="group border border-base-300 rounded-md px-2 py-1 mb-1">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-1"> <div class="flex items-center space-x-1">
<span class="mx-1 w-3 h-3 shrink-0 rounded-full <%= colors[index] %>"></span> <span class="mx-1 w-3 h-3 shrink-0 rounded-full <%= colors[index % 10] %>"></span>
<span class="text-lg" dir="auto"> <span class="text-lg" dir="auto">
<%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %> <%= (@submission.template_submitters || @submission.template.submitters).find { |e| e['uuid'] == submitter&.uuid }&.dig('name') || "#{(index + 1).ordinalize} Submitter" %>
</span> </span>
@ -190,6 +195,11 @@
</a> </a>
</div> </div>
<% end %> <% end %>
<% if signed_in? && submitter && submitter.completed_at? && submitter.email == current_user.email && submitter.completed_at > 1.month.ago && can?(:update, @submission) %>
<div class="mt-2 mb-1">
<%= button_to t('resubmit'), submitters_resubmit_path(submitter), method: :put, class: 'btn btn-sm btn-primary w-full', form: { target: '_blank' }, data: { turbo: false } %>
</div>
<% end %>
</div> </div>
</div> </div>
<div class="px-1.5 mb-4"> <div class="px-1.5 mb-4">
@ -222,10 +232,12 @@
</div> </div>
<% elsif field['type'] == 'checkbox' %> <% elsif field['type'] == 'checkbox' %>
<%= svg_icon('check', class: 'w-6 h-6') %> <%= svg_icon('check', class: 'w-6 h-6') %>
<% elsif field['type'] == 'number' %>
<%= NumberUtils.format_number(value, field.dig('preferences', 'format')) %>
<% elsif field['type'] == 'date' %> <% elsif field['type'] == 'date' %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale) %> <%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale) %>
<% else %> <% else %>
<%= Array.wrap(value).join(', ') %> <div class="whitespace-pre-wrap"><%= Array.wrap(value).join(', ') %></div>
<% end %> <% end %>
</div> </div>
</div> </div>

@ -1,5 +1,5 @@
<% query_params = params.permit(:q).merge(filter_params) %> <% query_params = params.permit(:q).merge(filter_params) %>
<% if icon = { 'declined' => 'x_circle', 'expired' => 'clock_cancel', 'partially_completed' => 'clock_edit' }[params[:status]] %> <% if icon = { 'declined' => 'x_circle', 'expired' => 'clock_cancel', 'partially_completed' => 'clock_edit', 'sent' => 'send', 'opened' => 'mail_opened' }[params[:status]] %>
<div class="flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700"> <div class="flex h-10 px-2 py-1 text-lg items-center justify-between border text-center text-neutral font-semibold rounded-xl w-full md:w-34 border-neutral-700">
<%= link_to submissions_filter_path('status', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %> <%= link_to submissions_filter_path('status', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal' }, class: 'flex items-center space-x-1 w-full pr-1 md:max-w-[140px]' do %>
<%= svg_icon(icon, class: 'w-5 h-5 shrink-0') %> <%= svg_icon(icon, class: 'w-5 h-5 shrink-0') %>

@ -2,7 +2,7 @@
<div class="flex flex-col md:flex-row gap-2 mt-5"> <div class="flex flex-col md:flex-row gap-2 mt-5">
<div class="form-control w-full"> <div class="form-control w-full">
<div id="status" class="radio-select grid grid-cols-2 gap-2 px-1"> <div id="status" class="radio-select grid grid-cols-2 gap-2 px-1">
<% ['', 'pending', 'completed', 'partially_completed', 'declined', 'expired'].each do |status| %> <% ['', 'pending', 'completed', 'partially_completed', 'sent', 'opened', 'declined', 'expired'].each do |status| %>
<label class="radio-label cursor-pointer inline-flex items-center space-x-2"> <label class="radio-label cursor-pointer inline-flex items-center space-x-2">
<%= radio_button_tag 'status', status, params[:status] == status || (status == '' && params[:status].blank?), class: 'base-radio' %> <%= radio_button_tag 'status', status, params[:status] == status || (status == '' && params[:status].blank?), class: 'base-radio' %>
<span><%= t(status.presence || 'all') %></span> <span><%= t(status.presence || 'all') %></span>

@ -2,11 +2,9 @@
<div class="max-w-md mx-auto px-2 mt-12 mb-4"> <div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto"> <div class="space-y-6 mx-auto">
<div class="space-y-6"> <div class="space-y-6">
<% if Docuseal.multitenant? %> <div class="flex items-center justify-center">
<div class="flex items-center justify-center"> <%= render 'start_form/banner' %>
<%= render 'start_form/docuseal_logo' %> </div>
</div>
<% end %>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4"> <div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center"> <div class="flex items-center">
<div class="mr-3"> <div class="mr-3">

@ -0,0 +1 @@
<%= render partial: 'submissions/annotation', collection: annots, as: :annot %>

@ -1,5 +1,5 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid created_at], methods: %i[url filename content_type]) %> <% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid created_at], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %> <% data_fields = Submissions.filtered_conditions_fields(submitter).to_json %>
<% invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %> <% invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %>
<% optional_invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['optional_invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %> <% optional_invite_submitters = (submitter.submission.template_submitters || submitter.submission.template.submitters).select { |s| s['optional_invite_by_uuid'] == submitter.uuid && submitter.submission.submitters.none? { |e| e.uuid == s['uuid'] } }.to_json %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-schema="<%= schema.to_json %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template.preferences['completed_redirect_url'] %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template.preferences['completed_message'] || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-optional-invite-submitters="<%= optional_invite_submitters %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form> <submission-form data-is-demo="<%= Docuseal.demo? %>" data-schema="<%= schema.to_json %>" data-reuse-signature="<%= configs[:reuse_signature] %>" data-require-signing-reason="<%= configs[:require_signing_reason] %>" data-with-signature-id="<%= configs[:with_signature_id] %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'].presence || submitter.submission.template.preferences['completed_redirect_url'] %>" data-completed-message="<%= (configs[:completed_message]&.compact_blank.presence || submitter.submission.template.preferences['completed_message'] || {}).to_json %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.preferences.key?('go_to_last') ? submitter.preferences['go_to_last'] : submitter.opened_at? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(submitter.submission.account) %>" data-optional-invite-submitters="<%= optional_invite_submitters %>" data-invite-submitters="<%= invite_submitters %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>" data-previous-signature-value="<%= local_assigns[:signature_attachment]&.uuid %>" data-remember-signature="<%= configs[:prefill_signature] %>" data-dry-run="<%= local_assigns[:dry_run] %>" data-expand="<%= local_assigns[:expand] %>" data-scroll-padding="<%= local_assigns[:scroll_padding] %>" data-language="<%= I18n.locale.to_s.split('-').first %>"></submission-form>

@ -42,7 +42,8 @@
</download-button> </download-button>
<% end %> <% end %>
</div> </div>
<% if Templates.filter_undefined_submitters(@submitter.submission.template).size == 1 && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && !@submitter.template.archived_at? %> <% undefined_submitters = Templates.filter_undefined_submitters(@submitter.submission.template) %>
<% if undefined_submitters.size == 1 && undefined_submitters.first['uuid'] == @submitter.uuid && %w[api embed].exclude?(@submitter.submission.source) && @submitter.account.account_configs.find_or_initialize_by(key: AccountConfig::ALLOW_TO_RESUBMIT).value != false && !@submitter.template.archived_at? %>
<div class="divider uppercase"><%= t('or') %></div> <div class="divider uppercase"><%= t('or') %></div>
<toggle-submit class="block"> <toggle-submit class="block">
<%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@submitter.submission.template.slug), params: { submitter: { email: @submitter.email, phone: @submitter.phone, name: @submitter.name }, resubmit: @submitter.slug }, method: :put, class: 'white-button w-full' %> <%= button_to button_title(title: t('resubmit'), disabled_with: t('resubmit'), icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@submitter.submission.template.slug), params: { submitter: { email: @submitter.email, phone: @submitter.phone, name: @submitter.name }, resubmit: @submitter.slug }, method: :put, class: 'white-button w-full' %>

@ -47,8 +47,8 @@
<div class="relative my-4 shadow-md"> <div class="relative my-4 shadow-md">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>"> <img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" height="<%= page.metadata['height'] %>">
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute"> <div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %> <% if annots = document_annots_index[index] %>
<%= render 'submissions/annotation', annot: %> <%= render 'submit_form/annotations', annots: %>
<% end %> <% end %>
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %> <% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']].presence || (field['default_value'].present? ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %> <% value = values[field['uuid']].presence || (field['default_value'].present? ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>

@ -17,7 +17,7 @@
</submitter-item> </submitter-item>
</div> </div>
<div> <div>
<%= render 'submissions/send_email', f:, template: @submitter.template, submitter: @submitter, resend_email: @submitter.sent_at?, submitter_email_message: @submitter_email_message, disable_save_as_default_template_option: true %> <%= render 'submissions/send_email', f:, template: @submitter.template, submitter: @submitter, resend_email: @submitter.sent_at?, submitter_email_message: @submitter_email_message, disable_save_as_default_template_option: true, message_per_submitter: false %>
<%= render 'submissions/send_sms', f:, resend_sms: @submitter.sent_at? %> <%= render 'submissions/send_sms', f:, resend_sms: @submitter.sent_at? %>
</div> </div>
<div class="form-control mt-4"> <div class="form-control mt-4">

@ -2,7 +2,7 @@
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %> <% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<div class="flex justify-between items-center w-full mb-4"> <div class="flex justify-between items-center w-full mb-4">
<div class="flex items-center flex-grow min-w-0"> <div class="flex items-center flex-grow min-w-0">
<% if has_archived || @pagy.count > 0 %> <% if has_archived || @pagy.count > 0 || @template_folders.present? %>
<div class="mr-2"> <div class="mr-2">
<%= render 'dashboard/toggle_view', selected: 'templates' %> <%= render 'dashboard/toggle_view', selected: 'templates' %>
</div> </div>

@ -83,21 +83,71 @@
<div class="collapse-content"> <div class="collapse-content">
<%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %> <%= form_for @template, url: template_preferences_path(@template), method: :post, html: { autocomplete: 'off', class: 'mt-1' }, data: { close_on_submit: false } do |f| %>
<toggle-on-submit data-element-id="email_saved_alert1"></toggle-on-submit> <toggle-on-submit data-element-id="email_saved_alert1"></toggle-on-submit>
<%= f.fields_for :preferences, Struct.new(:request_email_subject, :request_email_body).new(*(@template.preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY).value.values_at('subject', 'body'))) do |ff| %> <%= tag.input id: 'request_email_per_submitter', value: '1', name: 'request_email_per_submitter', class: 'peer', type: 'checkbox', hidden: true, checked: @template.preferences['submitters'].to_a.size > 1 %>
<div class="form-control"> <div class="peer-checked:hidden">
<%= ff.label :request_email_subject, t('email_subject'), class: 'label' %> <%= f.fields_for :preferences, Struct.new(:request_email_subject, :request_email_body).new(*(@template.preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY).value.values_at('subject', 'body'))) do |ff| %>
<%= ff.text_field :request_email_subject, required: true, class: 'base-input', dir: 'auto' %> <div class="form-control">
</div> <div class="flex justify-between">
<div class="form-control"> <%= ff.label :request_email_subject, t('email_subject'), class: 'label' %>
<div class="flex items-center"> <% if @template.submitters.size > 1 && @template.submitters.size < 5 %>
<%= ff.label :request_email_body, t('email_body'), class: 'label' %> <label for="request_email_per_submitter" class="label underline">
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>"> <%= t('edit_per_party') %>
<%= svg_icon('info_circle', class: 'w-4 h-4') %> </label>
</span> <% end %>
</div>
<%= ff.text_field :request_email_subject, required: true, class: 'base-input', dir: 'auto' %>
</div> </div>
<autoresize-textarea> <div class="form-control">
<%= ff.text_area :request_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %> <div class="flex items-center">
</autoresize-textarea> <%= ff.label :request_email_body, t('email_body'), class: 'label' %>
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
<autoresize-textarea>
<%= ff.text_area :request_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
</autoresize-textarea>
</div>
<% end %>
</div>
<% if @template.submitters.size > 1 && @template.submitters.size < 5 %>
<div class="hidden peer-checked:block">
<% options = @template.submitters.map { |e| [e['name'], "request_email_#{e['uuid']}"] } %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="flex relative px-1">
<ul class="tabs w-full flex flex-nowrap mb-2">
<% options.each_with_index do |(label, val), index| %>
<div class="w-full">
<%= f.radio_button :selected, val, checked: index.zero?, id: "#{val}_radio", data: { action: 'click:toggle-visible#trigger' }, class: 'hidden peer' %>
<%= f.label :selected, label, value: val, for: "#{val}_radio", class: 'tab w-full tab-lifted peer-checked:tab-active' %>
</div>
<% end %>
</ul>
</toggle-visible>
<%= f.fields_for :preferences do |ff| %>
<% @template.submitters.each_with_index do |submitter, index| %>
<div id="request_email_<%= submitter['uuid'] %>" class="<%= 'hidden' if index != 0 %>">
<% submitter_preferences = f.object.preferences['submitters'].to_a.find { |e| e['uuid'] == submitter['uuid'] } || {} %>
<%= ff.fields_for :submitters, Struct.new(:request_email_subject, :request_email_body).new(*(submitter_preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || @template.preferences.values_at('request_email_subject', 'request_email_body').compact_blank.presence || AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY).value.values_at('subject', 'body'))), index: nil do |fff| %>
<%= fff.hidden_field :uuid, value: submitter['uuid'] %>
<div class="form-control">
<%= fff.label :request_email_subject, t('email_subject'), class: 'label' %>
<%= fff.text_field :request_email_subject, required: true, class: 'base-input', dir: 'auto' %>
</div>
<div class="form-control">
<div class="flex items-center">
<%= fff.label :request_email_body, t('email_body'), class: 'label' %>
<span class="tooltip tooltip-right" data-tip="<%= t('use_following_placeholders_text_') %> <%= AccountConfig::DEFAULT_VALUES[AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY].call['body'].scan(/{.*?}/).join(', ') %>">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</div>
<autoresize-textarea>
<%= fff.text_area :request_email_body, required: true, class: 'base-input w-full py-2', dir: 'auto' %>
</autoresize-textarea>
</div>
<% end %>
</div>
<% end %>
<% end %>
</div> </div>
<% end %> <% end %>
<div class="form-control pt-2"> <div class="form-control pt-2">

@ -60,7 +60,7 @@
</span> </span>
</td> </td>
<td> <td>
<%= user.last_sign_in_at ? l(user.last_sign_in_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) : '-' %> <%= user.current_sign_in_at ? l(user.current_sign_in_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) : '-' %>
</td> </td>
<td class="flex items-center space-x-2 justify-end"> <td class="flex items-center space-x-2 justify-end">
<% if params[:status].blank? && can?(:update, user) && user.archived_at.blank? %> <% if params[:status].blank? && can?(:update, user) && user.archived_at.blank? %>

@ -20,10 +20,12 @@ en: &en
language_ko: 한국어 language_ko: 한국어
hi_there: Hi there hi_there: Hi there
thanks: Thanks thanks: Thanks
edit_per_party: Edit per party
reply_to: Reply to reply_to: Reply to
pending_by_me: Pending by me pending_by_me: Pending by me
partially_completed: Partially completed partially_completed: Partially completed
unarchive: Unarchive unarchive: Unarchive
signed: Signed
first_party: 'First Party' first_party: 'First Party'
remove_filter: Remove filter remove_filter: Remove filter
add: Add add: Add
@ -41,6 +43,7 @@ en: &en
hello_name: Hello %{name} hello_name: Hello %{name}
you_are_invited_to_product_name: You are invited to %{product_name} you_are_invited_to_product_name: You are invited to %{product_name}
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'You have been invited to %{account_name} %{product_name}. Please sign up using the link below:' you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'You have been invited to %{account_name} %{product_name}. Please sign up using the link below:'
sent_using_product_name_in_testing_mode_html: 'Sent using <a href="%{product_url}">%{product_name}</a> in testing mode'
sent_using_product_name_free_document_signing_html: 'Sent using <a href="%{product_url}">%{product_name}</a> free document signing.' sent_using_product_name_free_document_signing_html: 'Sent using <a href="%{product_url}">%{product_name}</a> free document signing.'
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Sign documents with trusted certificate provided by DocuSeal. Your documents and data are never shared with DocuSeal. PDF checksum is provided to generate a trusted signature. sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Sign documents with trusted certificate provided by DocuSeal. Your documents and data are never shared with DocuSeal. PDF checksum is provided to generate a trusted signature.
you_have_been_invited_to_submit_the_name_form: 'You have been invited to submit the "%{name}" form.' you_have_been_invited_to_submit_the_name_form: 'You have been invited to submit the "%{name}" form.'
@ -164,7 +167,7 @@ en: &en
schedule_account_for_deletion_: Schedule account for deletion? schedule_account_for_deletion_: Schedule account for deletion?
account_information_has_been_updated: Account information has been updated. account_information_has_been_updated: Account information has been updated.
should_be_a_valid_url: should be a valid URL should_be_a_valid_url: should be a valid URL
your_account_removal_request_will_be_processed_within_2_weeks_please_contact_us_if_you_want_to_keep_your_account: Your account removal request will be processed within 2 weeks. Please contact us if you want to keep your account. your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account: Your account removal request will be processed within 2 months. Please contact us if you want to keep your account.
test_mode: Test mode test_mode: Test mode
copy: Copy copy: Copy
copied: Copied copied: Copied
@ -688,6 +691,22 @@ en: &en
tell_us_more_about_your_experience: Tell us more about your experience tell_us_more_about_your_experience: Tell us more about your experience
extremely_dissatisfied: Extremely Dissatisfied extremely_dissatisfied: Extremely Dissatisfied
extremely_satisfied: Extremely Satisfied extremely_satisfied: Extremely Satisfied
your_pro_plan_payment_is_overdue: Your Pro plan payment is overdue.
click_here_to_update_your_payment_details_and_clear_the_invoice_to_ensure_uninterrupted_service_html: <a href="%{url}" class="link">Click here</a> to update your payment details and clear the invoice to ensure uninterrupted service.
overdue_payment: Overdue Payment
your_pro_plan_has_been_suspended_due_to_unpaid_invoices_you_can_update_your_payment_details_to_settle_the_invoice_and_continue_using_docuseal_or_cancel_your_subscription: Your Pro Plan has been suspended due to unpaid invoices. You can update your payment details to settle the invoice and continue using DocuSeal or cancel your subscription.
manage_subscription: Manage Subscription
submission_created_by_email_html: '<b>Submission created</b> by %{email}'
submission_created_by_email_via_source_html: '<b>Submission created</b> by %{email} via %{source}'
submission_created_via_source_html: '<b>Submission created</b> via %{source}'
pro_user_seats_used: Pro user seats used
manage_plan: Manage plan
submission_sources:
api: API
bulk: Bulk Send
embed: Embedding
invie: Invite
link: Link
submission_event_names: submission_event_names:
send_email_to_html: '<b>Email sent</b> to %{submitter_name}' send_email_to_html: '<b>Email sent</b> to %{submitter_name}'
send_reminder_email_to_html: '<b>Reminder email sent</b> to %{submitter_name}' send_reminder_email_to_html: '<b>Reminder email sent</b> to %{submitter_name}'
@ -726,6 +745,8 @@ en: &en
read: Read your data read: Read your data
es: &es es: &es
edit_per_party: Editar por parte
signed: Firmado
reply_to: Responder a reply_to: Responder a
partially_completed: Parcialmente completado partially_completed: Parcialmente completado
pending_by_me: Pendiente por mi pending_by_me: Pendiente por mi
@ -749,6 +770,7 @@ es: &es
hello_name: Hola %{name} hello_name: Hola %{name}
you_are_invited_to_product_name: Estás invitado a %{product_name} you_are_invited_to_product_name: Estás invitado a %{product_name}
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Has sido invitado a %{account_name} %{product_name}. Por favor, regístrate usando el enlace a continuación:' you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Has sido invitado a %{account_name} %{product_name}. Por favor, regístrate usando el enlace a continuación:'
sent_using_product_name_in_testing_mode_html: 'Enviado usando <a href="%{product_url}">%{product_name}</a> en Modo de Prueba'
sent_using_product_name_free_document_signing_html: 'Enviado usando la firma de documentos gratuita de <a href="%{product_url}">%{product_name}</a>.' sent_using_product_name_free_document_signing_html: 'Enviado usando la firma de documentos gratuita de <a href="%{product_url}">%{product_name}</a>.'
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Firme documentos con un certificado de confianza proporcionado por DocuSeal. Sus documentos y datos nunca se comparten con DocuSeal. Se proporciona un checksum de PDF para generar una firma de confianza. sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Firme documentos con un certificado de confianza proporcionado por DocuSeal. Sus documentos y datos nunca se comparten con DocuSeal. Se proporciona un checksum de PDF para generar una firma de confianza.
hi_there: Hola hi_there: Hola
@ -872,7 +894,7 @@ es: &es
schedule_account_for_deletion_: ¿Programar la eliminación de la cuenta? schedule_account_for_deletion_: ¿Programar la eliminación de la cuenta?
account_information_has_been_updated: La información de la cuenta ha sido actualizada. account_information_has_been_updated: La información de la cuenta ha sido actualizada.
should_be_a_valid_url: debe ser una URL válida should_be_a_valid_url: debe ser una URL válida
your_account_removal_request_will_be_processed_within_2_weeks_please_contact_us_if_you_want_to_keep_your_account: Tu solicitud de eliminación de cuenta se procesará en un plazo de 2 semanas. Por favor contáctanos si deseas mantener tu cuenta. your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account: Tu solicitud de eliminación de cuenta se procesará en un plazo de 2 meses. Por favor contáctanos si deseas mantener tu cuenta.
test_mode: Modo de prueba test_mode: Modo de prueba
copy: Copiar copy: Copiar
copied: Copiado copied: Copiado
@ -1396,6 +1418,22 @@ es: &es
tell_us_more_about_your_experience: Cuéntanos más sobre tu experiencia tell_us_more_about_your_experience: Cuéntanos más sobre tu experiencia
extremely_dissatisfied: Extremadamente insatisfecho extremely_dissatisfied: Extremadamente insatisfecho
extremely_satisfied: Extremadamente satisfecho extremely_satisfied: Extremadamente satisfecho
your_pro_plan_payment_is_overdue: El pago de tu plan Pro está atrasado.
click_here_to_update_your_payment_details_and_clear_the_invoice_to_ensure_uninterrupted_service_html: '<a href="%{url}" class="link">Haz clic aquí</a> para actualizar tus datos de pago y liquidar la factura para garantizar un servicio ininterrumpido.'
overdue_payment: Pago Atrasado
your_pro_plan_has_been_suspended_due_to_unpaid_invoices_you_can_update_your_payment_details_to_settle_the_invoice_and_continue_using_docuseal_or_cancel_your_subscription: Tu plan Pro ha sido suspendido debido a facturas impagas. Puedes actualizar tus datos de pago para liquidar la factura y seguir usando DocuSeal o cancelar tu suscripción.
manage_subscription: Gestionar Suscripción
submission_created_by_email_html: '<b>Envío creado</b> por %{email}'
submission_created_by_email_via_source_html: '<b>Envío creado</b> por %{email} a través de %{source}'
submission_created_via_source_html: '<b>Envío creado</b> a través de %{source}'
pro_user_seats_used: Plazas de usuario Pro en uso
manage_plan: Gestionar plan
submission_sources:
api: API
bulk: Envío masivo
embed: Integración
invite: Invitación
link: Enlace
submission_event_names: submission_event_names:
send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}' send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}'
send_reminder_email_to_html: '<b>Correo de recordatorio enviado</b> a %{submitter_name}' send_reminder_email_to_html: '<b>Correo de recordatorio enviado</b> a %{submitter_name}'
@ -1434,6 +1472,8 @@ es: &es
read: Leer tus datos read: Leer tus datos
it: &it it: &it
edit_per_party: Modifica per partito
signed: Firmato
reply_to: Rispondi a reply_to: Rispondi a
pending_by_me: In sospeso da me pending_by_me: In sospeso da me
add: Aggiungi add: Aggiungi
@ -1456,6 +1496,7 @@ it: &it
hello_name: Ciao %{name} hello_name: Ciao %{name}
you_are_invited_to_product_name: Sei stato invitato a %{product_name} you_are_invited_to_product_name: Sei stato invitato a %{product_name}
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Sei stato invitato a %{account_name} %{product_name}. Registrati utilizzando il link qui sotto:' you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Sei stato invitato a %{account_name} %{product_name}. Registrati utilizzando il link qui sotto:'
sent_using_product_name_in_testing_mode_html: 'Inviato utilizzando <a href="%{product_url}">%{product_name}</a> in Modalità di Test'
sent_using_product_name_free_document_signing_html: 'Inviato utilizzando la firma di documenti gratuita di <a href="%{product_url}">%{product_name}</a>.' sent_using_product_name_free_document_signing_html: 'Inviato utilizzando la firma di documenti gratuita di <a href="%{product_url}">%{product_name}</a>.'
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: "Firma documenti con un certificato di fiducia fornito da DocuSeal. I tuoi documenti e i tuoi dati non vengono mai condivisi con DocuSeal. Il checksum PDF è fornito per generare una firma di fiducia." sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: "Firma documenti con un certificato di fiducia fornito da DocuSeal. I tuoi documenti e i tuoi dati non vengono mai condivisi con DocuSeal. Il checksum PDF è fornito per generare una firma di fiducia."
hi_there: Ciao hi_there: Ciao
@ -1579,7 +1620,7 @@ it: &it
schedule_account_for_deletion_: "Programmare l'eliminazione dell'account?" schedule_account_for_deletion_: "Programmare l'eliminazione dell'account?"
account_information_has_been_updated: "Le informazioni dell'account sono state aggiornate." account_information_has_been_updated: "Le informazioni dell'account sono state aggiornate."
should_be_a_valid_url: deve essere un URL valido should_be_a_valid_url: deve essere un URL valido
your_account_removal_request_will_be_processed_within_2_weeks_please_contact_us_if_you_want_to_keep_your_account: "La tua richiesta di rimozione dell'account sarà elaborata entro 2 settimane. Contattaci se desideri mantenere il tuo account." your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account: "La tua richiesta di rimozione dell'account sarà elaborata entro 2 mesi. Contattaci se desideri mantenere il tuo account."
test_mode: Modalità di test test_mode: Modalità di test
copy: Copia copy: Copia
copied: Copiato copied: Copiato
@ -2103,6 +2144,22 @@ it: &it
tell_us_more_about_your_experience: Raccontaci di più sulla tua esperienza tell_us_more_about_your_experience: Raccontaci di più sulla tua esperienza
extremely_dissatisfied: Estremamente insoddisfatto extremely_dissatisfied: Estremamente insoddisfatto
extremely_satisfied: Estremamente soddisfatto extremely_satisfied: Estremamente soddisfatto
your_pro_plan_payment_is_overdue: Il pagamento del tuo piano Pro è in ritardo.
click_here_to_update_your_payment_details_and_clear_the_invoice_to_ensure_uninterrupted_service_html: '<a href="%{url}" class="link">Fai clic qui</a> per aggiornare i tuoi dati di pagamento e saldare la fattura per garantire un servizio ininterrotto.'
overdue_payment: Pagamento Scaduto
your_pro_plan_has_been_suspended_due_to_unpaid_invoices_you_can_update_your_payment_details_to_settle_the_invoice_and_continue_using_docuseal_or_cancel_your_subscription: Il tuo piano Pro è stato sospeso a causa di fatture non pagate. Puoi aggiornare i tuoi dati di pagamento per saldare la fattura e continuare a utilizzare DocuSeal o annullare l'abbonamento.
manage_subscription: Gestisci Abbonamento
submission_created_by_email_html: '<b>Invio creato</b> da %{email}'
submission_created_by_email_via_source_html: '<b>Invio creato</b> da %{email} tramite %{source}'
submission_created_via_source_html: '<b>Invio creato</b> tramite %{source}'
pro_user_seats_used: Posti utente Pro in uso
manage_plan: Gestisci piano
submission_sources:
api: API
bulk: Invio massivo
embed: Incorporamento
invite: Invito
link: Link
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-mail inviato</b> a %{submitter_name}' send_email_to_html: '<b>E-mail inviato</b> a %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail di promemoria inviato</b> a %{submitter_name}' send_reminder_email_to_html: '<b>E-mail di promemoria inviato</b> a %{submitter_name}'
@ -2141,6 +2198,8 @@ it: &it
read: Leggi i tuoi dati read: Leggi i tuoi dati
fr: &fr fr: &fr
edit_per_party: Éditer par partie
signed: Signé
reply_to: Répondre à reply_to: Répondre à
partially_completed: Partiellement complété partially_completed: Partiellement complété
pending_by_me: En attente par moi pending_by_me: En attente par moi
@ -2164,6 +2223,7 @@ fr: &fr
hello_name: Bonjour %{name} hello_name: Bonjour %{name}
you_are_invited_to_product_name: Vous êtes invité à %{product_name} you_are_invited_to_product_name: Vous êtes invité à %{product_name}
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Vous avez été invité à %{account_name} %{product_name}. Veuillez vous inscrire en utilisant le lien ci-dessous:' you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Vous avez été invité à %{account_name} %{product_name}. Veuillez vous inscrire en utilisant le lien ci-dessous:'
sent_using_product_name_in_testing_mode_html: 'Envoyé en utilisant <a href="%{product_url}">%{product_name}</a> en Mode Test'
sent_using_product_name_free_document_signing_html: 'Envoyé en utilisant la signature de documents gratuite de <a href="%{product_url}">%{product_name}</a>.' sent_using_product_name_free_document_signing_html: 'Envoyé en utilisant la signature de documents gratuite de <a href="%{product_url}">%{product_name}</a>.'
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Signez des documents avec un certificat de confiance fourni par DocuSeal. Vos documents et données ne sont jamais partagés avec DocuSeal. Un checksum PDF est fourni pour générer une signature de confiance. sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Signez des documents avec un certificat de confiance fourni par DocuSeal. Vos documents et données ne sont jamais partagés avec DocuSeal. Un checksum PDF est fourni pour générer une signature de confiance.
hi_there: Bonjour hi_there: Bonjour
@ -2288,7 +2348,7 @@ fr: &fr
schedule_account_for_deletion_: Programmer la suppression du compte? schedule_account_for_deletion_: Programmer la suppression du compte?
account_information_has_been_updated: Les informations du compte ont été mises à jour. account_information_has_been_updated: Les informations du compte ont été mises à jour.
should_be_a_valid_url: doit être une URL valide should_be_a_valid_url: doit être une URL valide
your_account_removal_request_will_be_processed_within_2_weeks_please_contact_us_if_you_want_to_keep_your_account: Votre demande de suppression du compte sera traitée dans un délai de 2 semaines. Veuillez nous contacter si vous souhaitez conserver votre compte. your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account: Votre demande de suppression du compte sera traitée dans un délai de 2 mois. Veuillez nous contacter si vous souhaitez conserver votre compte.
test_mode: Mode test test_mode: Mode test
copy: Copier copy: Copier
copied: Copié copied: Copié
@ -2812,6 +2872,22 @@ fr: &fr
tell_us_more_about_your_experience: Parlez-nous davantage de votre expérience tell_us_more_about_your_experience: Parlez-nous davantage de votre expérience
extremely_dissatisfied: Extrêmement insatisfait extremely_dissatisfied: Extrêmement insatisfait
extremely_satisfied: Extrêmement satisfait extremely_satisfied: Extrêmement satisfait
your_pro_plan_payment_is_overdue: Le paiement de votre plan Pro est en retard.
click_here_to_update_your_payment_details_and_clear_the_invoice_to_ensure_uninterrupted_service_html: '<a href="%{url}" class="link">Cliquez ici</a> pour mettre à jour vos informations de paiement et régler la facture afin de garantir un service ininterrompu.'
overdue_payment: Paiement En Retard
your_pro_plan_has_been_suspended_due_to_unpaid_invoices_you_can_update_your_payment_details_to_settle_the_invoice_and_continue_using_docuseal_or_cancel_your_subscription: Votre plan Pro a été suspendu en raison de factures impayées. Vous pouvez mettre à jour vos informations de paiement pour régler la facture et continuer à utiliser DocuSeal ou annuler votre abonnement.
manage_subscription: Gérer l'Abonnement
submission_created_by_email_html: '<b>Soumission créée</b> par %{email}'
submission_created_by_email_via_source_html: '<b>Soumission créée</b> par %{email} via %{source}'
submission_created_via_source_html: '<b>Soumission créée</b> via %{source}'
pro_user_seats_used: Places utilisateur Pro en cours d'utilisation
manage_plan: Gérer le plan
submission_sources:
api: API
bulk: Envoi en masse
embed: Intégration
invite: Invitation
link: Lien
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-mail envoyé</b> à %{submitter_name}' send_email_to_html: '<b>E-mail envoyé</b> à %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail de rappel envoyé</b> à %{submitter_name}' send_reminder_email_to_html: '<b>E-mail de rappel envoyé</b> à %{submitter_name}'
@ -2850,6 +2926,8 @@ fr: &fr
read: Lire vos données read: Lire vos données
pt: &pt pt: &pt
edit_per_party: Edita por festa
signed: Assinado
reply_to: Responder a reply_to: Responder a
partially_completed: Parcialmente concluído partially_completed: Parcialmente concluído
pending_by_me: Pendente por mim pending_by_me: Pendente por mim
@ -2873,6 +2951,7 @@ pt: &pt
hello_name: Olá %{name} hello_name: Olá %{name}
you_are_invited_to_product_name: Você está convidado para %{product_name} you_are_invited_to_product_name: Você está convidado para %{product_name}
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Você foi convidado para %{account_name} %{product_name}. Inscreva-se usando o link abaixo:' you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Você foi convidado para %{account_name} %{product_name}. Inscreva-se usando o link abaixo:'
sent_using_product_name_in_testing_mode_html: 'Enviado usando <a href="%{product_url}">%{product_name}</a> no Modo de Teste'
sent_using_product_name_free_document_signing_html: 'Enviado usando a assinatura gratuita de documentos de <a href="%{product_url}">%{product_name}</a>.' sent_using_product_name_free_document_signing_html: 'Enviado usando a assinatura gratuita de documentos de <a href="%{product_url}">%{product_name}</a>.'
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Assine documentos com certificado confiável fornecido pela DocuSeal. Seus documentos e dados nunca são compartilhados com a DocuSeal. O checksum do PDF é fornecido para gerar uma assinatura confiável. sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Assine documentos com certificado confiável fornecido pela DocuSeal. Seus documentos e dados nunca são compartilhados com a DocuSeal. O checksum do PDF é fornecido para gerar uma assinatura confiável.
hi_there: Olá hi_there: Olá
@ -2996,7 +3075,7 @@ pt: &pt
schedule_account_for_deletion_: Agendar exclusão da conta? schedule_account_for_deletion_: Agendar exclusão da conta?
account_information_has_been_updated: As informações da conta foram atualizadas. account_information_has_been_updated: As informações da conta foram atualizadas.
should_be_a_valid_url: deve ser um URL válido should_be_a_valid_url: deve ser um URL válido
your_account_removal_request_will_be_processed_within_2_weeks_please_contact_us_if_you_want_to_keep_your_account: Seu pedido de remoção da conta será processado em até 2 semanas. Entre em contato conosco se você quiser manter sua conta. your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account: Seu pedido de remoção da conta será processado em até 2 meses. Entre em contato conosco se você quiser manter sua conta.
test_mode: Modo de teste test_mode: Modo de teste
copy: Copiar copy: Copiar
copied: Copiado copied: Copiado
@ -3520,6 +3599,22 @@ pt: &pt
tell_us_more_about_your_experience: Conte-nos mais sobre sua experiência tell_us_more_about_your_experience: Conte-nos mais sobre sua experiência
extremely_dissatisfied: Extremamente insatisfeito extremely_dissatisfied: Extremamente insatisfeito
extremely_satisfied: Extremamente satisfeito extremely_satisfied: Extremamente satisfeito
your_pro_plan_payment_is_overdue: O pagamento do seu plano Pro está atrasado.
click_here_to_update_your_payment_details_and_clear_the_invoice_to_ensure_uninterrupted_service_html: '<a href="%{url}" class="link">Clique aqui</a> para atualizar seus dados de pagamento e quitar a fatura para garantir um serviço ininterrupto.'
overdue_payment: Pagamento Atrasado
your_pro_plan_has_been_suspended_due_to_unpaid_invoices_you_can_update_your_payment_details_to_settle_the_invoice_and_continue_using_docuseal_or_cancel_your_subscription: Seu plano Pro foi suspenso devido a faturas não pagas. Você pode atualizar seus dados de pagamento para quitar a fatura e continuar usando o DocuSeal ou cancelar sua assinatura.
manage_subscription: Gerenciar Assinatura
submission_created_by_email_html: '<b>Envio criado</b> por %{email}'
submission_created_by_email_via_source_html: '<b>Envio criado</b> por %{email} via %{source}'
submission_created_via_source_html: '<b>Envio criado</b> via %{source}'
pro_user_seats_used: Lugares de usuário Pro em uso
manage_plan: Gerenciar plano
submission_sources:
api: API
bulk: Envio em massa
embed: Incorporação
invite: Convite
link: Link
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-mail enviado</b> para %{submitter_name}' send_email_to_html: '<b>E-mail enviado</b> para %{submitter_name}'
send_reminder_email_to_html: '<b>E-mail de lembrete enviado</b> para %{submitter_name}' send_reminder_email_to_html: '<b>E-mail de lembrete enviado</b> para %{submitter_name}'
@ -3558,6 +3653,8 @@ pt: &pt
read: Ler seus dados read: Ler seus dados
de: &de de: &de
edit_per_party: Bearbeiten pro Partei
signed: Unterschrieben
reply_to: Antworten auf reply_to: Antworten auf
partially_completed: Teilweise abgeschlossen partially_completed: Teilweise abgeschlossen
pending_by_me: Ausstehend von mir pending_by_me: Ausstehend von mir
@ -3581,6 +3678,7 @@ de: &de
hello_name: Hallo %{name} hello_name: Hallo %{name}
you_are_invited_to_product_name: Sie sind eingeladen zu %{product_name} you_are_invited_to_product_name: Sie sind eingeladen zu %{product_name}
you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Sie wurden zu %{account_name} %{product_name} eingeladen. Bitte registrieren Sie sich über den folgenden Link:' you_have_been_invited_to_account_name_product_name_please_sign_up_using_the_link_below_: 'Sie wurden zu %{account_name} %{product_name} eingeladen. Bitte registrieren Sie sich über den folgenden Link:'
sent_using_product_name_in_testing_mode_html: 'Gesendet über <a href="%{product_url}">%{product_name}</a> im Testmodus'
sent_using_product_name_free_document_signing_html: 'Gesendet mit der kostenlosen Dokumentensignierung von <a href="%{product_url}">%{product_name}</a>.' sent_using_product_name_free_document_signing_html: 'Gesendet mit der kostenlosen Dokumentensignierung von <a href="%{product_url}">%{product_name}</a>.'
sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Unterzeichnen Sie Dokumente mit einem vertrauenswürdigen Zertifikat von DocuSeal. Ihre Dokumente und Daten werden niemals mit DocuSeal geteilt. Eine PDF-Prüfziffer wird bereitgestellt, um eine vertrauenswürdige Signatur zu generieren. sign_documents_with_trusted_certificate_provided_by_docu_seal_your_documents_and_data_are_never_shared_with_docu_seal_p_d_f_checksum_is_provided_to_generate_a_trusted_signature: Unterzeichnen Sie Dokumente mit einem vertrauenswürdigen Zertifikat von DocuSeal. Ihre Dokumente und Daten werden niemals mit DocuSeal geteilt. Eine PDF-Prüfziffer wird bereitgestellt, um eine vertrauenswürdige Signatur zu generieren.
hi_there: Hallo hi_there: Hallo
@ -3704,7 +3802,7 @@ de: &de
schedule_account_for_deletion_: Konto zur Löschung einplanen? schedule_account_for_deletion_: Konto zur Löschung einplanen?
account_information_has_been_updated: Die Kontoinformationen wurden aktualisiert. account_information_has_been_updated: Die Kontoinformationen wurden aktualisiert.
should_be_a_valid_url: sollte eine gültige URL sein should_be_a_valid_url: sollte eine gültige URL sein
your_account_removal_request_will_be_processed_within_2_weeks_please_contact_us_if_you_want_to_keep_your_account: Deine Anfrage zur Kontolöschung wird innerhalb von 2 Wochen bearbeitet. Bitte kontaktiere uns, wenn du dein Konto behalten möchtest. your_account_removal_request_will_be_processed_within_2_months_please_contact_us_if_you_want_to_keep_your_account: Deine Anfrage zur Kontolöschung wird innerhalb von 2 Monaten bearbeitet. Bitte kontaktiere uns, wenn du dein Konto behalten möchtest.
test_mode: Testmodus test_mode: Testmodus
copy: Kopieren copy: Kopieren
copied: Kopiert copied: Kopiert
@ -4228,6 +4326,22 @@ de: &de
tell_us_more_about_your_experience: Erzählen Sie uns mehr über Ihre Erfahrung tell_us_more_about_your_experience: Erzählen Sie uns mehr über Ihre Erfahrung
extremely_dissatisfied: Extrem unzufrieden extremely_dissatisfied: Extrem unzufrieden
extremely_satisfied: Extrem zufrieden extremely_satisfied: Extrem zufrieden
your_pro_plan_payment_is_overdue: Ihre Zahlung für den Pro-Plan ist überfällig.
click_here_to_update_your_payment_details_and_clear_the_invoice_to_ensure_uninterrupted_service_html: '<a href="%{url}" class="link">Klicken Sie hier</a>, um Ihre Zahlungsdaten zu aktualisieren und die Rechnung zu begleichen, um einen unterbrechungsfreien Service sicherzustellen.'
overdue_payment: Überfällige Zahlung
your_pro_plan_has_been_suspended_due_to_unpaid_invoices_you_can_update_your_payment_details_to_settle_the_invoice_and_continue_using_docuseal_or_cancel_your_subscription: Ihr Pro-Plan wurde aufgrund unbezahlter Rechnungen ausgesetzt. Sie können Ihre Zahlungsdaten aktualisieren, um die Rechnung zu begleichen und DocuSeal weiterhin zu nutzen, oder Ihr Abonnement kündigen.
manage_subscription: Abonnement Verwalten
submission_created_by_email_html: '<b>Übermittlung erstellt</b> von %{email}'
submission_created_by_email_via_source_html: '<b>Übermittlung erstellt</b> durch %{email} über %{source}'
submission_created_via_source_html: '<b>Übermittlung erstellt</b> über %{source}'
pro_user_seats_used: Verwendete Pro-Benutzerplätze
manage_plan: Plan verwalten
submission_sources:
api: API
bulk: Massenversand
embed: Einbettung
invite: Einladung
link: Link
submission_event_names: submission_event_names:
send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}' send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}'
send_reminder_email_to_html: '<b>Erinnerungs-E-Mail gesendet</b> an %{submitter_name}' send_reminder_email_to_html: '<b>Erinnerungs-E-Mail gesendet</b> an %{submitter_name}'

@ -12,6 +12,7 @@ Rails.application.routes.draw do
root 'dashboard#index' root 'dashboard#index'
get 'up' => 'rails/health#show' get 'up' => 'rails/health#show'
get 'manifest' => 'pwa#manifest'
devise_for :users, devise_for :users,
path: '/', only: %i[sessions passwords omniauth_callbacks], path: '/', only: %i[sessions passwords omniauth_callbacks],
@ -71,6 +72,7 @@ Rails.application.routes.draw do
resources :submissions, only: %i[index], controller: 'submissions_dashboard' resources :submissions, only: %i[index], controller: 'submissions_dashboard'
resources :submissions, only: %i[show destroy] do resources :submissions, only: %i[show destroy] do
resources :unarchive, only: %i[create], controller: 'submissions_unarchive' resources :unarchive, only: %i[create], controller: 'submissions_unarchive'
resources :events, only: %i[index], controller: 'submission_events'
end end
resources :submitters, only: %i[edit update] resources :submitters, only: %i[edit update]
resources :console_redirect, only: %i[index] resources :console_redirect, only: %i[index]
@ -79,6 +81,7 @@ Rails.application.routes.draw do
resource :testing_account, only: %i[show destroy] resource :testing_account, only: %i[show destroy]
resources :testing_api_settings, only: %i[index] resources :testing_api_settings, only: %i[index]
resources :submitters_autocomplete, only: %i[index] resources :submitters_autocomplete, only: %i[index]
resources :submitters_resubmit, only: %i[update]
resources :template_folders_autocomplete, only: %i[index] resources :template_folders_autocomplete, only: %i[index]
resources :webhook_secret, only: %i[show update] resources :webhook_secret, only: %i[show update]
resources :webhook_preferences, only: %i[update] resources :webhook_preferences, only: %i[update]
@ -145,9 +148,7 @@ Rails.application.routes.draw do
get :completed get :completed
end end
resources :send_submission_email, only: %i[create] do resources :send_submission_email, only: %i[create]
get :success, on: :collection
end
resources :submitters, only: %i[], param: 'slug' do resources :submitters, only: %i[], param: 'slug' do
resources :download, only: %i[index], controller: 'submissions_download' resources :download, only: %i[index], controller: 'submissions_download'

@ -25,7 +25,7 @@ module Abilities
account_ids = [user.account_id, TemplateSharing::ALL_ID] account_ids = [user.account_id, TemplateSharing::ALL_ID]
template.template_sharings.any? do |e| template.template_sharings.any? do |e|
e.account_id.in?(account_ids) && (ability.nil? || e.ability == ability) e.account_id.in?(account_ids) && (ability.nil? || e.ability == 'manage' || e.ability == ability)
end end
end end
end end

@ -153,6 +153,10 @@ module Accounts
EncryptedConfig.exists?(key: EncryptedConfig::EMAIL_SMTP_KEY) EncryptedConfig.exists?(key: EncryptedConfig::EMAIL_SMTP_KEY)
end end
def can_send_invitation_emails?(_account)
true
end
def normalize_timezone(timezone) def normalize_timezone(timezone)
tzinfo = TZInfo::Timezone.get(ActiveSupport::TimeZone::MAPPING[timezone] || timezone) tzinfo = TZInfo::Timezone.get(ActiveSupport::TimeZone::MAPPING[timezone] || timezone)

@ -69,6 +69,10 @@ module Docuseal
@default_pkcs ||= GenerateCertificate.load_pkcs(Docuseal::CERTS) @default_pkcs ||= GenerateCertificate.load_pkcs(Docuseal::CERTS)
end end
def enable_pwa?
true
end
def trusted_certs def trusted_certs
@trusted_certs ||= @trusted_certs ||=
ENV['TRUSTED_CERTS'].to_s.gsub('\\n', "\n").split("\n\n").map do |base64| ENV['TRUSTED_CERTS'].to_s.gsub('\\n', "\n").split("\n\n").map do |base64|

@ -4,7 +4,16 @@ module NumberUtils
FORMAT_LOCALES = { FORMAT_LOCALES = {
'dot' => 'de', 'dot' => 'de',
'space' => 'fr', 'space' => 'fr',
'comma' => 'en' 'comma' => 'en',
'usd' => 'en',
'eur' => 'fr',
'gbp' => 'en'
}.freeze
CURRENCY_SYMBOLS = {
'usd' => '$',
'eur' => '€',
'gbp' => '£'
}.freeze }.freeze
module_function module_function
@ -12,7 +21,9 @@ module NumberUtils
def format_number(number, format) def format_number(number, format)
locale = FORMAT_LOCALES[format] locale = FORMAT_LOCALES[format]
if locale if CURRENCY_SYMBOLS[format]
ApplicationController.helpers.number_to_currency(number, locale:, precision: 2, unit: CURRENCY_SYMBOLS[format])
elsif locale
ApplicationController.helpers.number_with_delimiter(number, locale:) ApplicationController.helpers.number_with_delimiter(number, locale:)
else else
number number

@ -24,6 +24,10 @@ module PdfIcons
StringIO.new(logo_new_data) StringIO.new(logo_new_data)
end end
def stamp_logo_io
StringIO.new(stamp_logo_data)
end
def check_data def check_data
@check_data ||= PATH.join('check.png').read @check_data ||= PATH.join('check.png').read
end end
@ -39,4 +43,8 @@ module PdfIcons
def logo_new_data def logo_new_data
@logo_new_data ||= PATH.join('logo_new.png').read @logo_new_data ||= PATH.join('logo_new.png').read
end end
def stamp_logo_data
@stamp_logo_data ||= PATH.join('stamp-logo.png').read
end
end end

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

@ -0,0 +1,26 @@
# frozen_string_literal: true
module SendWebhookRequest
USER_AGENT = 'DocuSeal.com Webhook'
module_function
def call(webhook_url, event_type:, data:)
Faraday.post(webhook_url.url) do |req|
req.headers['Content-Type'] = 'application/json'
req.headers['User-Agent'] = USER_AGENT
req.headers.merge!(webhook_url.secret.to_h) if webhook_url.secret.present?
req.body = {
event_type: event_type,
timestamp: Time.current,
data: data
}.to_json
req.options.read_timeout = 8
req.options.open_timeout = 8
end
rescue Faraday::Error
nil
end
end

@ -107,20 +107,27 @@ module Submissions
def normalize_email(email) def normalize_email(email)
return if email.blank? return if email.blank?
return email.downcase if email.to_s.include?(',')
return email.downcase if email.to_s.include?('.gob') return email.downcase if email.to_s.include?(',') ||
return email.downcase if email.to_s.include?('.om') email.to_s.match?(/\.(?:gob|om|mm|cm|et|mo|nz|za|ie)\z/) ||
return email.downcase if email.to_s.include?('.mm') email.to_s.exclude?('.')
return email.downcase if email.to_s.include?('.cm')
return email.downcase if email.to_s.include?('.et')
return email.downcase if email.to_s.include?('.mo')
return email.downcase if email.to_s.include?('.nz')
return email.downcase if email.to_s.include?('.za')
return email.downcase unless email.to_s.include?('.')
fixed_email = EmailTypo.call(email.delete_prefix('<')) fixed_email = EmailTypo.call(email.delete_prefix('<'))
Rails.logger.info("Fixed email #{email.split('@').last}") if fixed_email != email.downcase.delete_prefix('<').strip return fixed_email if fixed_email == email
domain = email.to_s.split('@').last.to_s.downcase
fixed_domain = fixed_email.to_s.split('@').last
return email.downcase if domain == fixed_domain
if DidYouMean::Levenshtein.distance(domain, fixed_domain) > 3
Rails.logger.info("Skipped email fix #{domain}")
return email.downcase
end
Rails.logger.info("Fixed email #{domain}") if fixed_email != email.downcase.delete_prefix('<').strip
fixed_email fixed_email
end end
@ -135,31 +142,57 @@ module Submissions
values ||= submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } values ||= submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
next unless check_document_conditions(item, values, fields_uuid_index, include_submitter_uuid:) next unless check_item_conditions(item, values, fields_uuid_index, include_submitter_uuid:)
end end
item item
end end
end end
def check_document_conditions(item, values, fields_index, include_submitter_uuid: nil) def filtered_conditions_fields(submitter, only_submitter_fields: true)
fields = submitter.submission.template_fields || submitter.submission.template.fields
fields_uuid_index = nil
values = nil
fields.filter_map do |field|
next if field['submitter_uuid'] != submitter.uuid && only_submitter_fields
if field['conditions'].present?
fields_uuid_index ||= fields.index_by { |f| f['uuid'] }
values ||= submitter.submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
submitter_conditions = []
next unless check_item_conditions(field, values, fields_uuid_index,
include_submitter_uuid: submitter.uuid,
submitter_conditions_acc: submitter_conditions)
field = field.merge('conditions' => submitter_conditions) if submitter_conditions != field['conditions']
end
field
end
end
def check_item_conditions(item, values, fields_index, include_submitter_uuid: nil, submitter_conditions_acc: nil)
return true if item['conditions'].blank? return true if item['conditions'].blank?
item['conditions'].all? do |condition| item['conditions'].each_with_object([]) do |condition, acc|
result = result =
if fields_index[condition['field_uuid']]['submitter_uuid'] == include_submitter_uuid if fields_index[condition['field_uuid']]['submitter_uuid'] == include_submitter_uuid
submitter_conditions_acc << condition if submitter_conditions_acc
true true
else else
Submitters::SubmitValues.check_field_condition(condition, values, fields_index) Submitters::SubmitValues.check_field_condition(condition, values, fields_index)
end end
item['conditions'].each_with_object([]) do |c, acc| if condition['operation'] == 'or'
if c['operation'] == 'or' acc.push(acc.pop || result)
acc.push(acc.pop || result) else
else acc.push(result)
acc.push(result) end
end end.exclude?(false)
end.exclude?(false)
end
end end
end end

@ -36,8 +36,8 @@ module Submissions
is_order_sent = submitters_order == 'random' || index.zero? is_order_sent = submitters_order == 'random' || index.zero?
build_submitter(submission:, attrs: submitter_attrs, uuid:, build_submitter(submission:, attrs: submitter_attrs,
is_order_sent:, user:, uuid:, is_order_sent:, user:, params:,
preferences: preferences.merge(submission_preferences)) preferences: preferences.merge(submission_preferences))
end end
@ -69,6 +69,16 @@ module Submissions
end end
end end
def submitter_message_preferences(uuid, params)
return {} if params[:request_email_per_submitter] != '1'
return {} if params[:is_custom_message] != '1'
{
'subject' => params.dig('submitter_preferences', uuid, 'subject'),
'body' => params.dig('submitter_preferences', uuid, 'body')
}.compact_blank
end
def maybe_set_template_fields(submission, submitters_attrs, default_submitter_uuid: nil) def maybe_set_template_fields(submission, submitters_attrs, default_submitter_uuid: nil)
template_fields = (submission.template_fields || submission.template.fields).deep_dup template_fields = (submission.template_fields || submission.template.fields).deep_dup
@ -175,9 +185,10 @@ module Submissions
uuid || template.submitters[index]&.dig('uuid') uuid || template.submitters[index]&.dig('uuid')
end end
def build_submitter(submission:, attrs:, uuid:, is_order_sent:, user:, preferences:) def build_submitter(submission:, attrs:, uuid:, is_order_sent:, user:, preferences:, params:)
email = Submissions.normalize_email(attrs[:email]) email = Submissions.normalize_email(attrs[:email])
submitter_preferences = Submitters.normalize_preferences(submission.account, user, attrs) submitter_preferences = Submitters.normalize_preferences(submission.account, user,
attrs.merge(submitter_message_preferences(uuid, params)))
values = attrs[:values] || {} values = attrs[:values] || {}
phone_field_uuid = find_phone_field(submission, values)&.dig('uuid') phone_field_uuid = find_phone_field(submission, values)&.dig('uuid')

@ -37,30 +37,45 @@ module Submissions
submissions.where(created_by_user_id: user&.id || -1) submissions.where(created_by_user_id: user&.id || -1)
end end
# rubocop:disable Metrics/MethodLength
def filter_by_status(submissions, filters) def filter_by_status(submissions, filters)
submissions = submissions.pending if filters[:status] == 'pending' case filters[:status]
submissions = submissions.completed if filters[:status] == 'completed' when 'pending'
submissions = submissions.declined if filters[:status] == 'declined' submissions.pending
submissions = submissions.expired if filters[:status] == 'expired' when 'completed'
submissions.completed
if filters[:status] == 'partially_completed' when 'declined'
submissions = submissions.declined
submissions.joins(:submitters) when 'expired'
.group(:id) submissions.expired
.having(Arel::Nodes::NamedFunction.new( when 'sent'
'COUNT', [Arel::Nodes::NamedFunction.new('NULLIF', submissions.joins(:submitters)
[Submitter.arel_table[:completed_at].eq(nil), .where(submitters: { opened_at: nil, completed_at: nil, declined_at: nil })
Arel::Nodes.build_quoted(false)])] .where.not(submitters: { sent_at: nil })
).gt(0)) .group(:id)
.having(Arel::Nodes::NamedFunction.new( when 'opened'
'COUNT', [Arel::Nodes::NamedFunction.new('NULLIF', submissions.joins(:submitters)
[Submitter.arel_table[:completed_at].not_eq(nil), .where(submitters: { completed_at: nil, declined_at: nil })
Arel::Nodes.build_quoted(false)])] .where.not(submitters: { opened_at: nil })
).gt(0)) .group(:id)
when 'partially_completed'
submissions.joins(:submitters)
.group(:id)
.having(Arel::Nodes::NamedFunction.new(
'COUNT', [Arel::Nodes::NamedFunction.new('NULLIF',
[Submitter.arel_table[:completed_at].eq(nil),
Arel::Nodes.build_quoted(false)])]
).gt(0))
.having(Arel::Nodes::NamedFunction.new(
'COUNT', [Arel::Nodes::NamedFunction.new('NULLIF',
[Submitter.arel_table[:completed_at].not_eq(nil),
Arel::Nodes.build_quoted(false)])]
).gt(0))
else
submissions
end end
submissions
end end
# rubocop:enable Metrics/MethodLength
def filter_by_created_at(submissions, filters) def filter_by_created_at(submissions, filters)
submissions = submissions.where(created_at: filters[:created_at_from]..) if filters[:created_at_from].present? submissions = submissions.where(created_at: filters[:created_at_from]..) if filters[:created_at_from].present?

@ -175,7 +175,7 @@ module Submissions
padding: [15, 0, 8, 0], padding: [15, 0, 8, 0],
position: :float) position: :float)
unless submission.source.in?(%w[embed api]) if show_verify?(submission)
column.formatted_text([{ link: verify_url, text: I18n.t('verify'), style: :link }], column.formatted_text([{ link: verify_url, text: I18n.t('verify'), style: :link }],
font_size: 9, padding: [15, 0, 10, 0], position: :float, text_align: :right) font_size: 9, padding: [15, 0, 10, 0], position: :float, text_align: :right)
end end
@ -425,6 +425,10 @@ module Submissions
def maybe_add_background(_canvas, _submission, _page_size); end def maybe_add_background(_canvas, _submission, _page_size); end
def show_verify?(submission)
!submission.source.in?(%w[embed api])
end
def add_logo(column, _submission = nil) def add_logo(column, _submission = nil)
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float) column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)

@ -23,9 +23,15 @@ module Submissions
**Submissions::GenerateResultAttachments.build_signing_params(submitter, pkcs, tsa_url) **Submissions::GenerateResultAttachments.build_signing_params(submitter, pkcs, tsa_url)
} }
pdf.sign(io, **sign_params) begin
pdf.sign(io, **sign_params)
rescue HexaPDF::MalformedPDFError => e
Rollbar.error(e) if defined?(Rollbar)
pdf.sign(io, write_options: { incremental: false }, **sign_params)
end
else else
pdf.write(io) pdf.write(io, incremental: true, validate: false)
end end
Submissions::GenerateResultAttachments.maybe_enable_ltv(io, sign_params) Submissions::GenerateResultAttachments.maybe_enable_ltv(io, sign_params)

@ -18,9 +18,12 @@ module Submissions
TEXT_TOP_MARGIN = 1 TEXT_TOP_MARGIN = 1
MAX_PAGE_ROTATE = 20 MAX_PAGE_ROTATE = 20
COURIER_FONT = 'Courier'
A4_SIZE = [595, 842].freeze A4_SIZE = [595, 842].freeze
TESTING_FOOTER = 'Testing Document - NOT LEGALLY BINDING' TESTING_FOOTER = 'Testing Document - NOT LEGALLY BINDING'
DEFAULT_FONTS = %w[Times Helvetica Courier].freeze
MISSING_GLYPH_REPLACE = { MISSING_GLYPH_REPLACE = {
'▪' => '-', '▪' => '-',
@ -188,7 +191,9 @@ module Submissions
fill_color = field.dig('preferences', 'color').presence fill_color = field.dig('preferences', 'color').presence
font = pdf.fonts.add(field.dig('preferences', 'font').presence || FONT_NAME) font_name = field.dig('preferences', 'font')
font_name = FONT_NAME unless font_name.in?(DEFAULT_FONTS)
font = pdf.fonts.add(font_name)
value = submitter.values[field['uuid']] value = submitter.values[field['uuid']]
value = field['default_value'] if field['type'] == 'heading' value = field['default_value'] if field['type'] == 'heading'
@ -435,18 +440,19 @@ module Submissions
value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', ')) value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', '))
text = HexaPDF::Layout::TextFragment.create(value, font:, text_params = { font:, fill_color:, font_size: }
fill_color:, text_params[:line_height] = text_params[:font_size] * 1.6 if font_name == COURIER_FONT
font_size:)
text = HexaPDF::Layout::TextFragment.create(value, **text_params)
lines = layouter.fit([text], area['w'] * width, height).lines lines = layouter.fit([text], area['w'] * width, height).lines
box_height = lines.sum(&:height) box_height = lines.sum(&:height)
if preferences_font_size.blank? && box_height > (area['h'] * height) + 1 if preferences_font_size.blank? && box_height > (area['h'] * height) + 1
text = HexaPDF::Layout::TextFragment.create(value, text_params[:font_size] = (font_size / 1.4).to_i
font:, text_params[:line_height] = text_params[:font_size] * 1.6 if font_name == COURIER_FONT
fill_color:,
font_size: (font_size / 1.4).to_i) text = HexaPDF::Layout::TextFragment.create(value, **text_params)
lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines
@ -454,10 +460,10 @@ module Submissions
end end
if preferences_font_size.blank? && box_height > (area['h'] * height) + 1 if preferences_font_size.blank? && box_height > (area['h'] * height) + 1
text = HexaPDF::Layout::TextFragment.create(value, text_params[:font_size] = (font_size / 1.9).to_i
font:, text_params[:line_height] = text_params[:font_size] * 1.6 if font_name == COURIER_FONT
fill_color:,
font_size: (font_size / 1.9).to_i) text = HexaPDF::Layout::TextFragment.create(value, **text_params)
lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines

@ -123,10 +123,22 @@ module Submitters
filename = ReplaceEmailVariables.call(filename_format, submitter:) filename = ReplaceEmailVariables.call(filename_format, submitter:)
filename = filename.gsub('{document.name}', blob.filename.base) filename = filename.gsub('{document.name}', blob.filename.base)
filename = filename.gsub(' - {submission.status}') do
if submitter.submission.submitters.all?(&:completed_at?)
status =
if submitter.submission.template_fields.any? { |f| f['type'] == 'signature' }
I18n.t(:signed)
else
I18n.t(:completed)
end
" - #{status}"
end
end
filename = filename.gsub( filename = filename.gsub(
'{submission.completed_at}', '{submission.completed_at}',
I18n.l(submitter.completed_at.beginning_of_year.in_time_zone(submitter.account.timezone), format: :short) I18n.l(submitter.completed_at.in_time_zone(submitter.account.timezone), format: :short)
) )
"#{filename}.#{blob.filename.extension}" "#{filename}.#{blob.filename.extension}"

@ -98,7 +98,7 @@ module Submitters
end end
def load_logo(_submitter) def load_logo(_submitter)
PdfIcons.logo_io PdfIcons.stamp_logo_io
end end
end end
end end

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save