Compare commits

..
62 Commits
Author SHA1 Message Date
Alex Turchyn 744d45d2c5 Merge from docusealco/wip 2026-05-04 13:55:19 +03:00
Pete Matsyburka 310b16f54a use mcp source 2026-05-04 09:42:51 +03:00
Pete Matsyburka 6d3ad4a869 update gh stars 2026-05-03 18:37:56 +03:00
Alex Turchyn 1ce2448820 update create_template and send_documents annotations 2026-05-03 17:43:22 +03:00
Pete Matsyburka 07b4ada5be optimize edit json 2026-05-03 16:32:37 +03:00
Pete Matsyburka f70bf530ba hide qr link 2026-05-03 08:19:58 +03:00
Pete Matsyburka a26aa0f411 remove log 2026-05-03 07:27:24 +03:00
Pete Matsyburka 584577c21f fix ed.jp email typo 2026-05-01 13:50:17 +03:00
Pete Matsyburka 1503375eed respond to HEAD file proxy 2026-04-30 17:55:13 +03:00
Pete Matsyburka 2dec099f20 fix signature canvas minimize 2026-04-30 14:51:24 +03:00
Pete Matsyburka bf6eed4ea2 fix canvas error 2026-04-30 14:42:22 +03:00
Pete Matsyburka e0dc33cfb4 fix email domain search 2026-04-30 11:27:17 +03:00
Pete Matsyburka 9c9dc27537 fix new area order 2026-04-30 11:13:35 +03:00
Pete Matsyburka 7305637b8c sort appears on 2026-04-30 10:32:51 +03:00
Pete Matsyburka a6e22adf2e fix preview 2026-04-29 18:00:47 +03:00
Pete Matsyburka 809c6b1aa8 fix modal open 2026-04-29 10:27:02 +03:00
Pete Matsyburka 1536ded2a6 redis error log 2026-04-28 19:30:51 +03:00
Pete Matsyburka 76d54971ce fix test mode clone 2026-04-28 11:58:17 +03:00
Pete Matsyburka 6dec56198c add require email 2fa to submitter update 2026-04-28 11:58:17 +03:00
Pete Matsyburka d96d252df3 use post for test mode 2026-04-28 11:58:17 +03:00
Pete Matsyburka 3fca76a716 change state param 2026-04-28 11:58:17 +03:00
Pete Matsyburka a184fa11f1 move auth links 2026-04-28 11:58:17 +03:00
Pete Matsyburka e9c0b6a703 rate limit 2026-04-28 11:58:17 +03:00
Pete Matsyburka ec7df08496 adjust send submission email 2026-04-28 11:58:17 +03:00
Pete Matsyburka 6627d7eac5 fix erblint 2026-04-27 15:34:57 +03:00
Pete Matsyburka 1f8945d710 fix expired filter 2026-04-27 15:31:29 +03:00
Alex Turchyn daaa289a5c Merge from docusealco/wip 2026-04-27 13:43:34 +03:00
Pete Matsyburka 81a6a48d70 validate color 2026-04-26 15:13:19 +03:00
Pete Matsyburka b93c7cd261 add has_many document_metadata 2026-04-26 10:24:20 +03:00
Pete Matsyburka 570482e12d rename error 2026-04-25 12:39:26 +03:00
Pete Matsyburka 39407557f2 adjust page preview 2026-04-25 09:01:53 +03:00
Pete Matsyburka 3b040d558d adjust compression 2026-04-24 18:39:19 +03:00
Alex Turchyn 56ba3c2a52 add custom classes 2026-04-24 17:01:33 +03:00
Pete Matsyburka c7afe33c72 add time formats 2026-04-24 14:01:28 +03:00
Pete Matsyburka 82500c2d7d update dep 2026-04-24 10:56:19 +03:00
Pete Matsyburka fb1c1f117b remove unused const 2026-04-24 10:56:19 +03:00
Pete Matsyburka 7c6a7513fd add with custom field tab 2026-04-24 09:59:34 +03:00
Pete Matsyburka 6d13119ee0 input mode condition and formula 2026-04-22 10:58:50 +03:00
Pete Matsyburka 1e2c752937 detect existing fields 2026-04-22 10:27:18 +03:00
Pete Matsyburka e82faf8320 queue page field detection 2026-04-22 07:43:45 +03:00
Pete Matsyburka 4adb1001b0 add rotate incremental config 2026-04-21 12:35:00 +03:00
Pete Matsyburka 992a1b26c0 adjust tooltip position 2026-04-20 20:18:07 +03:00
Pete Matsyburka c07bc6687c add metadata param 2026-04-20 15:43:41 +03:00
Alex Turchyn eea44bda34 Merge from docusealco/wip 2026-04-20 13:57:33 +03:00
Pete Matsyburka 7cdf263da1 add screen reader mode 2026-04-19 20:18:52 +03:00
Pete Matsyburka ee65a5693c improve accessibility 2026-04-19 10:08:16 +03:00
Pete Matsyburka 0af6ccf35f pipeline field detection 2026-04-19 09:01:08 +03:00
Pete Matsyburka 888f1ec6df adjust mcp 2026-04-18 08:33:42 +03:00
Pete Matsyburka c95a8616ac deduplicate submitter uuids 2026-04-17 22:01:03 +03:00
Pete Matsyburka 5ea6289b7a fix test mode modal 2026-04-16 09:00:43 +03:00
Pete Matsyburka d4a79ca5db adjust dynamic editor 2026-04-15 10:06:41 +03:00
Pete Matsyburka 70015ce1c4 adjust dynamic editor 2026-04-14 14:14:25 +03:00
Pete Matsyburka 6b85c28944 add error message 2026-04-14 10:51:12 +03:00
Pete Matsyburka 6c289cf273 optimize build 2026-04-14 09:37:27 +03:00
Pete Matsyburka a64bc3c618 change button style 2026-04-14 09:21:39 +03:00
Pete Matsyburka 46cf1e3067 add log 2026-04-14 09:13:33 +03:00
Pete Matsyburka 565e1eb2bc adjust validation 2026-04-13 19:55:07 +03:00
Pete Matsyburka e689687805 fix erblint 2026-04-13 19:21:24 +03:00
Pete Matsyburka 3c3b61fb47 adjust reason field 2026-04-13 19:18:55 +03:00
Pete Matsyburka 1355d350c5 fix xlsx boolean value 2026-04-13 18:13:38 +03:00
Pete Matsyburka fda911e178 add submitter field validation 2026-04-13 16:32:03 +03:00
Pete Matsyburka f995e1864c add shared link qr code 2026-04-13 13:59:00 +03:00
120 changed files with 2890 additions and 590 deletions

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@ Lint/MissingSuper:
Enabled: false Enabled: false
Metrics/ParameterLists: Metrics/ParameterLists:
Max: 10 Max: 12
Metrics/MethodLength: Metrics/MethodLength:
Max: 30 Max: 30
+1 -1
View File
@@ -48,7 +48,7 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app WORKDIR /app
RUN apk add --no-cache libpq vips redis vips-heif fontconfig onnxruntime RUN apk add --no-cache libpq vips redis vips-heif onnxruntime
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
+1 -1
View File
@@ -179,7 +179,7 @@ GEM
dotenv (3.2.0) dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
email_typo (0.2.3) email_typo (0.2.3)
erb (6.0.2) erb (6.0.4)
erb_lint (0.9.0) erb_lint (0.9.0)
activesupport activesupport
better_html (>= 2.0.1) better_html (>= 2.0.1)
@@ -33,9 +33,15 @@ module Api
else else
http_cache_forever public: true do http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes' response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition] if request.head?
response.headers['Content-Type'] = blob.content_type_for_serving
head :ok
else
send_blob_stream blob, disposition: params[:disposition]
end
response.headers['Content-Length'] = blob.byte_size.to_s
end end
end end
end end
@@ -57,8 +63,6 @@ module Api
return if !require_ttl && !require_auth return if !require_ttl && !require_auth
end end
Rollbar.error('Blob unauthorized') if defined?(Rollbar)
raise CanCan::AccessDenied raise CanCan::AccessDenied
end end
end end
+6 -3
View File
@@ -161,9 +161,7 @@ module Api
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter) submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
end end
submitter.values = submitter.values.transform_values do |v| submitter.values = Submitters::SubmitValues.replace_current_date_placeholders(submitter)
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end
end end
submitter submitter
@@ -205,10 +203,15 @@ module Api
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms') submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to') submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
if submitter_preferences.key?('require_phone_2fa') if submitter_preferences.key?('require_phone_2fa')
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa'] submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
end end
if submitter_preferences.key?('require_email_2fa')
submitter.preferences['require_email_2fa'] = submitter_preferences['require_email_2fa']
end
if submitter_preferences.key?('go_to_last') if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last'] submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end end
@@ -41,7 +41,7 @@ class PreviewDocumentPageController < ActionController::API
end end
def find_or_create_document_tempfile_path(attachment) def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/#{attachment.uuid}" file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f| File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX) f.flock(File::LOCK_EX)
@@ -1,6 +1,14 @@
# frozen_string_literal: true # frozen_string_literal: true
class RevealAccessTokenController < ApplicationController class RevealAccessTokenController < ApplicationController
rate_limit to: 4, within: 1.minute, only: %i[create], by: -> { current_user.id }, with: lambda {
Rollbar.error('Rate limit api key') if defined?(Rollbar)
render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show',
locals: { error_message: I18n.t(:too_many_attempts) }),
status: :unprocessable_content
}
def show def show
authorize!(:manage, current_user.access_token) authorize!(:manage, current_user.access_token)
end end
@@ -14,7 +14,7 @@ class SendSubmissionEmailController < ApplicationController
template = Template.find_by!(slug: params[:template_slug]) template = Template.find_by!(slug: params[:template_slug])
@submitter = @submitter =
Submitter.completed.where(submission: template.submissions).find_by!(email: params[:email].to_s.downcase) Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
elsif params[:submission_slug] elsif params[:submission_slug]
submission = Submission.find_by(slug: params[:submission_slug]) submission = Submission.find_by(slug: params[:submission_slug])
@@ -27,9 +27,11 @@ class SendSubmissionEmailController < ApplicationController
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug]) @submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
end end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes) if @submitter
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter) SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
end
respond_to do |f| respond_to do |f|
f.html { render :success } f.html { render :success }
@@ -79,6 +79,8 @@ class SubmitFormController < ApplicationController
render json: { field_uuid: e.message }, status: :unprocessable_content render json: { field_uuid: e.message }, status: :unprocessable_content
rescue Submitters::SubmitValues::ValidationError => e rescue Submitters::SubmitValues::ValidationError => e
Rollbar.warning("Validation error #{@submitter.id}: #{e.message}") if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content render json: { error: e.message }, status: :unprocessable_content
end end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class SubmitFormMetadataController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :not_found if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template&.archived_at? ||
submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
submission = submitter.submission
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: submitter.uuid)
documents = schema.filter_map do |item|
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
end
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
text_runs = documents.to_h do |document|
[
document.uuid,
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs
]
end
render json: { text_runs: }
end
end
+7 -2
View File
@@ -1,6 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
class TemplatesController < ApplicationController class TemplatesController < ApplicationController
TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug
schema fields submitters variables_schema preferences
shared_link source archived_at created_at updated_at].freeze
load_and_authorize_resource :template load_and_authorize_resource :template
def show def show
@@ -33,10 +37,11 @@ class TemplatesController < ApplicationController
).call ).call
@template_data = @template_data =
@template.as_json.merge( @template.as_json(only: TEMPLATE_FIELDS).merge(
documents: @template.schema_documents.as_json( documents: @template.schema_documents.as_json(
only: %i[id uuid],
methods: %i[metadata signed_key], methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } } include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
) )
).to_json ).to_json
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class TemplatesShareLinkQrController < ApplicationController
load_and_authorize_resource :template
def show
return render :disabled, layout: 'plain' unless @template.shared_link?
shared_link_url = start_form_url(slug: @template.slug, host: form_link_host)
@qr_svg_code = RQRCode::QRCode.new(shared_link_url, level: :m).as_svg(viewbox: true)
@page_size =
if TimeUtils.timezone_abbr(current_account.timezone, Time.current.beginning_of_year).in?(TimeUtils::US_TIMEZONES)
'Letter'
else
'A4'
end
render :show, layout: false
end
end
@@ -3,7 +3,7 @@
class TestingAccountsController < ApplicationController class TestingAccountsController < ApplicationController
skip_authorization_check only: :destroy skip_authorization_check only: :destroy
def show def create
authorize!(:manage, current_account) authorize!(:manage, current_account)
authorize!(:manage, current_user) authorize!(:manage, current_user)
@@ -32,9 +32,13 @@ class WebhookSettingsController < ApplicationController
def new; end def new; end
def create def create
@webhook_url.save! if @webhook_url.url.present?
@webhook_url.save!
redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved') redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')
else
redirect_back fallback_location: settings_webhooks_path
end
end end
def update def update
+2
View File
@@ -169,6 +169,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null, withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
withLogo: this.dataset.withLogo !== 'false', withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true', withFieldsDetection: this.dataset.withFieldsDetection === 'true',
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
editable: this.dataset.editable !== 'false', editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content, authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true, withCustomFields: true,
@@ -180,6 +181,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withConditions: this.dataset.withConditions === 'true', withConditions: this.dataset.withConditions === 'true',
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true', withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true', withGoogleDrive: this.dataset.withGoogleDrive === 'true',
pagePreviewFormat: this.dataset.pagePreviewFormat || '.jpg',
withReplaceAndCloneUpload: true, withReplaceAndCloneUpload: true,
withDownload: true, withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean), currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
+2 -1
View File
@@ -11,6 +11,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!this.canvas) return if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas const { width, height } = this.canvas
@@ -89,7 +90,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
} }
redrawCanvas (oldWidth, oldHeight) { redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) { if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight const sy = this.canvas.height / oldHeight
@@ -5,6 +5,12 @@ export default targetable(class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.addEventListener('click', () => this.downloadFiles()) this.addEventListener('click', () => this.downloadFiles())
this.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
this.downloadFiles()
}
})
} }
toggleState () { toggleState () {
+18
View File
@@ -0,0 +1,18 @@
export default class extends HTMLElement {
connectedCallback () {
const dialog = document.getElementById(this.dataset.target)
this.querySelector('button').addEventListener('click', () => {
if (dialog) {
dialog.inert = false
dialog.showModal()
}
})
if (dialog) {
dialog.addEventListener('close', () => {
dialog.inert = true
})
}
}
}
@@ -47,11 +47,13 @@ export default class extends HTMLElement {
this.classList.remove('hidden', '-translate-y-10', 'opacity-0') this.classList.remove('hidden', '-translate-y-10', 'opacity-0')
this.classList.add('translate-y-0', 'opacity-100') this.classList.add('translate-y-0', 'opacity-100')
this.inert = false
} }
hideButtons () { hideButtons () {
this.classList.remove('translate-y-0', 'opacity-100') this.classList.remove('translate-y-0', 'opacity-100')
this.classList.add('-translate-y-10', 'opacity-0') this.classList.add('-translate-y-10', 'opacity-0')
this.inert = true
setTimeout(() => { setTimeout(() => {
if (this.classList.contains('-translate-y-10')) { if (this.classList.contains('-translate-y-10')) {
+2 -1
View File
@@ -14,6 +14,7 @@ export default targetable(class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!this.canvas) return if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas const { width, height } = this.canvas
@@ -80,7 +81,7 @@ export default targetable(class extends HTMLElement {
} }
redrawCanvas (oldWidth, oldHeight) { redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) { if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight const sy = this.canvas.height / oldHeight
+2
View File
@@ -7,6 +7,7 @@ import FetchForm from './elements/fetch_form'
import ScrollButtons from './elements/scroll_buttons' import ScrollButtons from './elements/scroll_buttons'
import PageContainer from './elements/page_container' import PageContainer from './elements/page_container'
import SubmitForm from './elements/submit_form' import SubmitForm from './elements/submit_form'
import ModalButton from './elements/modal_button'
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options) const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
@@ -16,6 +17,7 @@ safeRegisterElement('fetch-form', FetchForm)
safeRegisterElement('scroll-buttons', ScrollButtons) safeRegisterElement('scroll-buttons', ScrollButtons)
safeRegisterElement('page-container', PageContainer) safeRegisterElement('page-container', PageContainer)
safeRegisterElement('submit-form', SubmitForm) safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('modal-button', ModalButton)
safeRegisterElement('submission-form', class extends HTMLElement { safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
@@ -0,0 +1,434 @@
<template>
<template
v-for="(pages, docUuid) in textRuns"
:key="docUuid"
>
<template
v-for="(_, pageIndex) in pages"
:key="pageIndex"
>
<template
v-for="(pageElem, i) in [findPageElement(docUuid, pageIndex)]"
:key="i"
>
<Teleport
v-if="pageElem"
:to="pageElem"
>
<template
v-for="(item, index) in sortedItemsForPage(docUuid, pageIndex)"
:key="index"
>
<template v-if="item.type === 'text_group'">
<span
class="absolute overflow-hidden text-transparent select-none pointer-events-none"
:style="{ left: item.x * 100 + '%', top: item.y * 100 + '%', width: item.w * 100 + '%', height: item.h * 100 + '%' }"
>{{ item.text }}</span>
<span
v-for="(run, runIndex) in item.items"
:key="runIndex"
aria-hidden="true"
class="absolute overflow-hidden text-transparent"
:style="{ left: run.x * 100 + '%', top: run.y * 100 + '%', width: run.w * 100 + '%', height: run.h * 100 + '%', fontSize: run.font_size ? (run.font_size / 10) + 'cqmin' : undefined, textAlign: 'justify', textAlignLast: 'justify', textJustify: 'inter-character' }"
>{{ run.text }}</span>
</template>
<FieldArea
v-else-if="item.type === 'field_area'"
:ref="setAreaRef"
v-model="values[item.field.uuid]"
:values="values"
:field="item.field"
:area="item.area"
:submittable="true"
:page-width="1400"
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
:field-index="item.fieldIndex"
:is-inline-size="isInlineSize"
:scroll-padding="scrollPadding"
:submitter="submitter"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:is-active="currentStep === item.step"
:with-label="withLabel && !withFieldPlaceholder && item.step.length < 2"
:is-value-set="item.step.some((f) => f.uuid in values)"
:attachments-index="attachmentsIndex"
@click="[$emit('focus-step', item.stepIndex), maybeScrollOnClick(item.field, item.area)]"
/>
<FieldArea
v-else-if="item.type === 'readonly_field_area'"
:model-value="readonlyConditionalFieldValues[item.field.uuid]"
:values="readonlyConditionalFieldValues"
:field="item.field"
:area="item.area"
:submittable="false"
:page-width="1400"
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
:field-index="item.fieldIndex"
:is-inline-size="isInlineSize"
:submitter="submitter"
:attachments-index="attachmentsIndex"
/>
<FieldArea
v-else-if="item.type === 'formula_area' && isMathLoaded"
:model-value="calculateFormula(item.field)"
:is-inline-size="isInlineSize"
:field="item.field"
:area="item.area"
:submittable="false"
:field-index="item.fieldIndex"
/>
</template>
</Teleport>
</template>
</template>
</template>
</template>
<script>
import FieldArea from './area'
import FormulaAreas from './formula_areas'
import FieldAreas from './areas'
export default {
name: 'AccessibilityAreas',
components: {
FieldArea
},
inject: ['baseUrl', 't'],
props: {
submitterSlug: {
type: String,
required: true
},
filledFieldsIndex: {
type: Object,
required: false,
default: null
},
steps: {
type: Array,
required: false,
default: () => []
},
readonlyConditionalFields: {
type: Array,
required: false,
default: () => []
},
readonlyConditionalFieldValues: {
type: Object,
required: false,
default: () => ({})
},
formulaFields: {
type: Array,
required: false,
default: () => []
},
values: {
type: Object,
required: false,
default: () => ({})
},
readonlyValues: {
type: Object,
required: false,
default: () => ({})
},
submitter: {
type: Object,
required: true
},
currentStep: {
type: Array,
required: false,
default: () => []
},
withFieldPlaceholder: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: false
},
withLabel: {
type: Boolean,
required: false,
default: true
},
scrollPadding: {
type: String,
required: false,
default: '-80px'
},
scrollEl: {
type: Object,
required: false,
default: null
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
}
},
emits: ['focus-step'],
data () {
return {
isMathLoaded: false,
math: null,
textRuns: {},
areaRefs: []
}
},
computed: {
fieldValuesIndex () {
return this.filledFieldsIndex || this.extractStaticValues()
},
isMobileContainer: FieldAreas.computed.isMobileContainer,
isInlineSize: FieldAreas.computed.isInlineSize,
fieldsUuidIndex () {
return this.formulaFields.reduce((acc, field) => {
acc[field.uuid] = field
return acc
}, {})
},
fieldAreasIndex () {
const index = Object.create(null)
this.steps.forEach((step, stepIndex) => {
step.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'field_area',
field,
area,
step,
stepIndex,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
})
return index
},
formulaAreasIndex () {
const index = Object.create(null)
this.formulaFields.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'formula_area',
field,
area,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
return index
},
readonlyFieldAreasIndex () {
const index = Object.create(null)
this.readonlyConditionalFields.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'readonly_field_area',
field,
area,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
return index
}
},
beforeUpdate () {
this.areaRefs = []
},
async mounted () {
const [metadataResult] = await Promise.all([
fetch(this.baseUrl + `/s/${this.submitterSlug}/metadata`, {
...this.fetchOptions
}).then((r) => r.json()).catch(() => ({})),
this.loadMath()
])
this.textRuns = metadataResult.text_runs || {}
},
methods: {
normalizeFormula: FormulaAreas.methods.normalizeFormula,
calculateFormula: FormulaAreas.methods.calculateFormula,
scrollInContainer: FieldAreas.methods.scrollInContainer,
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
scrollIntoField: FieldAreas.methods.scrollIntoField,
maybeScrollOnClick: FieldAreas.methods.maybeScrollOnClick,
setAreaRef (el) {
if (el) {
this.areaRefs.push(el)
}
},
async loadMath () {
if (this.formulaFields.length && !this.isMathLoaded) {
const { Calculator } = await import('./calculator')
this.math = new Calculator()
this.isMathLoaded = true
}
},
extractStaticValues () {
const result = Object.create(null)
const root = this.$root.$el?.parentNode?.getRootNode() || document
const pageContainers = root.querySelectorAll('page-container')
pageContainers.forEach((container) => {
const overlay = container.querySelector('[id^="page-"]')
if (!overlay) return
const parts = overlay.id.split('-')
const pageIndex = parseInt(parts[parts.length - 1])
const docUuid = parts.slice(1, -1).join('-')
const fieldValues = overlay.querySelectorAll('field-value')
if (!fieldValues.length) return
result[docUuid] ||= Object.create(null)
result[docUuid][pageIndex] = []
fieldValues.forEach((el) => {
const style = el.style
const x = parseFloat(style.left) / 100
const y = parseFloat(style.top) / 100
const w = parseFloat(style.width) / 100
const h = parseFloat(style.height) / 100
const text = el.textContent.trim()
if (text) {
result[docUuid][pageIndex].push({ type: 'static_value', text, x, y, w, h })
}
})
})
return result
},
findPageElement (docUuid, pageIndex) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${docUuid}-${pageIndex}`)
},
sortedItemsForPage (docUuid, pageIndex) {
const items = []
const pageTextRuns = this.textRuns[docUuid]?.[pageIndex] || []
pageTextRuns.forEach((run) => {
items.push({
type: 'text_run',
text: run.text,
x: run.x,
y: run.y,
w: run.w,
h: run.h,
font_size: run.font_size
})
})
const fieldAreas = this.fieldAreasIndex[docUuid]?.[pageIndex] || []
items.push(...fieldAreas)
const readonlyFieldAreas = this.readonlyFieldAreasIndex[docUuid]?.[pageIndex] || []
items.push(...readonlyFieldAreas)
const formulaAreas = this.formulaAreasIndex[docUuid]?.[pageIndex] || []
items.push(...formulaAreas)
const pageFieldValues = this.fieldValuesIndex[docUuid]?.[pageIndex] || []
items.push(...pageFieldValues)
items.sort((a, b) => {
const aCenterY = a.y + a.h / 2
const bCenterY = b.y + b.h / 2
const lineThreshold = Math.min(a.h, b.h) / 2
if (Math.abs(aCenterY - bCenterY) < lineThreshold) {
return a.x - b.x
}
return aCenterY - bCenterY
})
const grouped = []
let currentGroup = null
const closeGroup = () => {
if (!currentGroup) return
const groupItems = currentGroup.items
const minX = Math.min(...groupItems.map((i) => i.x))
const minY = Math.min(...groupItems.map((i) => i.y))
const maxEndX = Math.max(...groupItems.map((i) => i.x + i.w))
const maxEndY = Math.max(...groupItems.map((i) => i.y + i.h))
currentGroup.x = minX
currentGroup.y = minY
currentGroup.w = maxEndX - minX
currentGroup.h = maxEndY - minY
currentGroup.text = groupItems.map((i) => i.text).join(' ').replace(/\s+/g, ' ').trim()
grouped.push(currentGroup)
currentGroup = null
}
for (const item of items) {
const isTextLike = item.type === 'text_run' || item.type === 'static_value'
if (!isTextLike) {
closeGroup()
grouped.push(item)
continue
}
if (!currentGroup) {
currentGroup = { type: 'text_group', items: [] }
}
currentGroup.items.push(item)
}
closeGroup()
return grouped
}
}
}
</script>
+17 -2
View File
@@ -42,10 +42,25 @@ export default {
const areas = {} const areas = {}
this.field.areas?.forEach((area) => { this.field.areas?.forEach((area) => {
areas[area.attachment_uuid + area.page] ||= area areas[area.attachment_uuid] ||= []
areas[area.attachment_uuid].push(area)
}) })
return Object.values(areas).slice(0, 6) const sortedAreas = Object.values(areas).reduce((acc, group) => {
const seen = {}
const sortedGroup = [...group].sort((a, b) => a.page - b.page)
sortedGroup.forEach((area) => {
if (!seen[area.page]) {
seen[area.page] = true
acc.push(area)
}
})
return acc
}, [])
return sortedAreas.slice(0, 6)
} }
} }
} }
+67 -27
View File
@@ -1,9 +1,14 @@
<template> <template>
<div <div
class="flex absolute lg:text-base -outline-offset-1 field-area" class="flex absolute lg:text-base -outline-offset-1 focus-visible:outline-blue-500 focus-visible:outline-2 focus-visible:outline field-area"
dir="auto" dir="auto"
:style="[computedStyle, fontStyle]" :style="[computedStyle, fontStyle]"
:class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }" :class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
:role="submittable && !isNativeInputField ? 'button' : undefined"
:tabindex="submittable && !isNativeInputField ? 0 : undefined"
:aria-label="submittable && !isNativeInputField ? fieldAreaLabel : undefined"
@keydown.enter.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
@keydown.space.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
> >
<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"
@@ -18,6 +23,7 @@
width="100%" width="100%"
height="100%" height="100%"
class="max-h-10 text-base-content" class="max-h-10 text-base-content"
aria-hidden="true"
/> />
</span> </span>
</div> </div>
@@ -129,6 +135,7 @@
v-if="submittable" v-if="submittable"
type="checkbox" type="checkbox"
:value="false" :value="false"
:aria-label="field.name || fieldNames[field.type]"
class="aspect-square base-checkbox" class="aspect-square base-checkbox"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue" :checked="!!modelValue"
@@ -149,6 +156,8 @@
v-if="submittable" v-if="submittable"
type="radio" type="radio"
:value="false" :value="false"
:name="`radio-area-${field.uuid}`"
:aria-label="optionValue(option)"
class="aspect-square checked:checkbox checked:checkbox-xs" class="aspect-square checked:checkbox checked:checkbox-xs"
:class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue === optionValue(option)" :checked="!!modelValue && modelValue === optionValue(option)"
@@ -169,6 +178,7 @@
v-if="submittable" v-if="submittable"
type="checkbox" type="checkbox"
:value="false" :value="false"
:aria-label="optionValue(option)"
class="aspect-square base-checkbox" class="aspect-square base-checkbox"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }" :class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue.includes(optionValue(option))" :checked="!!modelValue && modelValue.includes(optionValue(option))"
@@ -400,6 +410,16 @@ export default {
kba: this.t('kba') kba: this.t('kba')
} }
}, },
isNativeInputField () {
return ['checkbox', 'radio', 'multiple'].includes(this.field.type)
},
fieldAreaLabel () {
const name = this.field.name || this.fieldNames[this.field.type] || this.field.type
if (this.area.option_uuid && this.option) {
return `${name} - ${this.optionValue(this.option)}`
}
return name
},
strikethroughWidth () { strikethroughWidth () {
if (this.isInlineSize) { if (this.isInlineSize) {
return '0.6cqmin' return '0.6cqmin'
@@ -507,7 +527,8 @@ export default {
try { try {
return this.formatDate( return this.formatDate(
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue), this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY') this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
{ withTimePlaceholders: this.modelValue === '{{date}}' }
) )
} catch { } catch {
return this.modelValue return this.modelValue
@@ -626,36 +647,55 @@ export default {
return number return number
} }
}, },
formatDate (date, format) { formatDate (date, format, { withTimePlaceholders = false } = {}) {
const monthFormats = { const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
M: 'numeric', const dayFormats = { D: 'numeric', DD: '2-digit' }
MM: '2-digit', const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
MMM: 'short', const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
MMMM: 'long' const minuteFormats = { m: 'numeric', mm: '2-digit' }
} const secondFormats = { s: 'numeric', ss: '2-digit' }
const dayFormats = { const hasTime = /[HhAasz]/.test(format)
D: 'numeric',
DD: '2-digit'
}
const yearFormats = { const opts = {
YYYY: 'numeric',
YYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)], day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)], month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)], year: yearFormats[format.match(/Y+/)]
timeZone: 'UTC' }
}).formatToParts(date)
return format if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
.replace(/D+/, parts.find((p) => p.type === 'day').value) if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
.replace(/M+/, parts.find((p) => p.type === 'month').value) if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
.replace(/Y+/, parts.find((p) => p.type === 'year').value) if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
if (!hasTime) opts.timeZone = 'UTC'
const partTypes = {
M: 'month',
D: 'day',
Y: 'year',
H: 'hour',
h: 'hour',
m: 'minute',
s: 'second',
z: 'timeZoneName',
A: 'dayPeriod',
a: 'dayPeriod'
}
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
if (withTimePlaceholders && /^(HH|hh|H|h|mm|m|ss|s|A|a)$/.test(token)) return '--'
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
if (token === 'A') return (value || '').toUpperCase()
if (token === 'a') return (value || '').toLowerCase()
return value
})
}, },
updateMultipleSelectValue (value) { updateMultipleSelectValue (value) {
if (this.modelValue?.includes(value)) { if (this.modelValue?.includes(value)) {
@@ -1,7 +1,11 @@
<template> <template>
<div> <div>
<div v-if="value.length"> <ul
<div v-if="value.length"
:aria-label="t('uploaded_files')"
class="list-none p-0 m-0"
>
<li
v-for="(val, index) in value" v-for="(val, index) in value"
:key="index" :key="index"
class="flex mb-2" class="flex mb-2"
@@ -20,23 +24,27 @@
<IconPaperclip <IconPaperclip
:width="16" :width="16"
class="flex-none" class="flex-none"
:heigh="16" :height="16"
aria-hidden="true"
/> />
<span> <span>
{{ attachmentsIndex[val].filename }} {{ attachmentsIndex[val].filename }}
</span> </span>
</a> </a>
<button <button
type="button"
class="remove-attachment-button" class="remove-attachment-button"
:aria-label="`${t('clear')} ${attachmentsIndex[val].filename}`"
@click.prevent="removeAttachment(val)" @click.prevent="removeAttachment(val)"
> >
<IconTrashX <IconTrashX
:width="18" :width="18"
:heigh="19" :height="19"
aria-hidden="true"
/> />
</button> </button>
</div> </li>
</div> </ul>
<template v-else> <template v-else>
<input <input
value="" value=""
+14 -3
View File
@@ -3,10 +3,13 @@
id="form_completed" id="form_completed"
class="mx-auto max-w-md flex flex-col completed-form" class="mx-auto max-w-md flex flex-col completed-form"
dir="auto" dir="auto"
role="status"
tabindex="-1"
> >
<div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto"> <div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
<IconCircleCheck <IconCircleCheck
class="inline text-green-600" class="inline text-green-600"
aria-hidden="true"
:width="30" :width="30"
:height="30" :height="30"
/> />
@@ -42,8 +45,12 @@
<IconInnerShadowTop <IconInnerShadowTop
v-if="isSendingCopy" v-if="isSendingCopy"
class="animate-spin" class="animate-spin"
aria-hidden="true"
/>
<IconMail
v-else
aria-hidden="true"
/> />
<IconMail v-else />
<span> <span>
{{ t('send_copy_via_email') }} {{ t('send_copy_via_email') }}
</span> </span>
@@ -57,8 +64,12 @@
<IconInnerShadowTop <IconInnerShadowTop
v-if="isDownloading" v-if="isDownloading"
class="animate-spin" class="animate-spin"
aria-hidden="true"
/>
<IconDownload
v-else
aria-hidden="true"
/> />
<IconDownload v-else />
<span> <span>
{{ t('download') }} {{ t('download') }}
</span> </span>
@@ -199,7 +210,7 @@ export default {
}) })
} }
document.querySelectorAll('#decline_button').forEach((button) => { document.querySelectorAll('#decline_button, #decline_button_mobile, #delegate_button, #delegate_button_mobile').forEach((button) => {
button.setAttribute('disabled', 'true') button.setAttribute('disabled', 'true')
}) })
}, },
+63 -17
View File
@@ -30,12 +30,16 @@
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button" class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
@click.prevent="[setCurrentDate(), $emit('focus')]" @click.prevent="[setCurrentDate(), $emit('focus')]"
> >
<IconCalendarCheck :width="16" /> <IconCalendarCheck
:width="16"
aria-hidden="true"
/>
{{ t('set_today') }} {{ t('set_today') }}
</button> </button>
</div> </div>
<div <div
v-if="field.description" v-if="field.description"
:id="field.uuid + '-desc'"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
dir="auto" dir="auto"
> >
@@ -51,12 +55,19 @@
:max="validationMax" :max="validationMax"
class="base-input !text-2xl text-center w-full" class="base-input !text-2xl text-center w-full"
:required="field.required" :required="field.required"
type="date" :aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:name="`values[${field.uuid}]`" :type="inputType"
:name="formatType === 'datetime' ? undefined : `values[${field.uuid}]`"
@keydown.enter="onEnter" @keydown.enter="onEnter"
@focus="$emit('focus')" @focus="$emit('focus')"
@paste="onPaste" @paste="onPaste"
> >
<input
v-if="formatType === 'datetime'"
type="hidden"
:name="`values[${field.uuid}]`"
:value="modelValue"
>
</div> </div>
</div> </div>
</template> </template>
@@ -92,14 +103,19 @@ export default {
}, },
emits: ['update:model-value', 'focus', 'submit'], emits: ['update:model-value', 'focus', 'submit'],
computed: { computed: {
formatType () {
const format = this.field.preferences?.format || ''
if (/[HhAasz]/.test(format)) return 'datetime'
if (format && !/[Dd]/.test(format)) return 'month'
return 'date'
},
inputType () {
return { datetime: 'datetime-local', month: 'month', date: 'date' }[this.formatType]
},
dateNowString () { dateNowString () {
const today = new Date() return this.formatDateValue(new Date())
const yyyy = today.getFullYear()
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}, },
validationMin () { validationMin () {
if (this.field.validation?.min) { if (this.field.validation?.min) {
@@ -116,6 +132,8 @@ export default {
} }
}, },
withToday () { withToday () {
if (this.formatType === 'datetime') return false
const todayDate = new Date().setHours(0, 0, 0, 0) const todayDate = new Date().setHours(0, 0, 0, 0)
if (this.validationMin) { if (this.validationMin) {
@@ -132,9 +150,25 @@ export default {
}, },
value: { value: {
set (value) { set (value) {
if (this.formatType === 'datetime' && value) {
const d = new Date(value)
if (!isNaN(d)) {
this.$emit('update:model-value', d.toISOString())
return
}
}
this.$emit('update:model-value', value) this.$emit('update:model-value', value)
}, },
get () { get () {
if (this.formatType === 'datetime') {
const d = new Date(this.modelValue)
return isNaN(d) ? '' : this.formatDateValue(d)
}
return this.modelValue return this.modelValue
} }
} }
@@ -158,20 +192,32 @@ export default {
const parsedDate = new Date(pasteData) const parsedDate = new Date(pasteData)
if (!isNaN(parsedDate)) { if (isNaN(parsedDate)) return
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000) this.setInputValue(parsedDate)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
}
}, },
setCurrentDate () { setCurrentDate () {
this.setInputValue(new Date())
},
setInputValue (date) {
const inputEl = this.$refs.input const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000) if (this.formatType === 'date') {
inputEl.valueAsDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
} else {
inputEl.value = this.formatDateValue(date)
}
inputEl.dispatchEvent(new Event('input', { bubbles: true })) inputEl.dispatchEvent(new Event('input', { bubbles: true }))
},
formatDateValue (date) {
const pad = (n) => String(n).padStart(2, '0')
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
if (this.formatType === 'month') return ymd.slice(0, 7)
if (this.formatType === 'datetime') return `${ymd}T${pad(date.getHours())}:${pad(date.getMinutes())}`
return ymd
} }
} }
} }
+2 -1
View File
@@ -39,8 +39,9 @@
ref="input" ref="input"
:multiple="multiple" :multiple="multiple"
:accept="accept" :accept="accept"
:aria-label="message"
type="file" type="file"
class="hidden" class="sr-only"
@change="onSelectFiles" @change="onSelectFiles"
> >
</label> </label>
+93 -9
View File
@@ -1,5 +1,52 @@
<template> <template>
<Teleport
v-if="withAccessibilityAreas === null && !isAccessibilityMode"
to="#sr_only_content"
>
<button
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
@click="isAccessibilityMode = true"
>
{{ t('enter_screen_reader_mode') }}
</button>
</Teleport>
<Teleport
v-for="item in (withAccessibilityAreas === null ? schema : [])"
:key="item.attachment_uuid"
:to="`#document-${item.attachment_uuid} .sr_only_content`"
>
<button
v-if="!isAccessibilityMode"
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
@click="isAccessibilityMode = true"
>
{{ t('enter_screen_reader_mode') }}
</button>
</Teleport>
<AccessibilityAreas
v-if="withAccessibilityAreas || isAccessibilityMode"
ref="areas"
:submitter-slug="submitterSlug"
:steps="stepFields"
:readonly-conditional-fields="readonlyConditionalFields"
:readonly-conditional-field-values="readonlyConditionalFieldValues"
:formula-fields="formulaFields"
:values="values"
:readonly-values="readonlyFieldValues"
:submitter="submitter"
:scroll-el="scrollEl"
:current-step="currentStepFields"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:with-label="withFieldLabels && !isAnonymousChecboxes && showFieldNames"
:scroll-padding="scrollPadding"
:attachments-index="attachmentsIndex"
:fetch-options="fetchOptions"
:filled-fields-index="filledFieldsIndex"
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
/>
<FieldAreas <FieldAreas
v-if="!withAccessibilityAreas && !isAccessibilityMode"
ref="areas" ref="areas"
:steps="stepFields" :steps="stepFields"
:values="values" :values="values"
@@ -14,6 +61,7 @@
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]" @focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
/> />
<FieldAreas <FieldAreas
v-if="!withAccessibilityAreas && !isAccessibilityMode"
:steps="readonlyConditionalFields.map((e) => [e])" :steps="readonlyConditionalFields.map((e) => [e])"
:values="readonlyConditionalFieldValues" :values="readonlyConditionalFieldValues"
:submitter="submitter" :submitter="submitter"
@@ -21,7 +69,7 @@
:submittable="false" :submittable="false"
/> />
<FormulaFieldAreas <FormulaFieldAreas
v-if="formulaFields.length" v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
:fields="formulaFields" :fields="formulaFields"
:readonly-values="readonlyFieldValues" :readonly-values="readonlyFieldValues"
:values="values" :values="values"
@@ -57,6 +105,7 @@
<IconInnerShadowTop <IconInnerShadowTop
v-if="isSubmittingComplete" v-if="isSubmittingComplete"
class="mr-1 animate-spin w-5 h-5" class="mr-1 animate-spin w-5 h-5"
aria-hidden="true"
/> />
<span> <span>
{{ t('complete') }} {{ t('complete') }}
@@ -88,6 +137,7 @@
class="absolute right-0 mr-4" class="absolute right-0 mr-4"
:width="20" :width="20"
:height="20" :height="20"
aria-hidden="true"
/> />
</button> </button>
<div <div
@@ -103,11 +153,13 @@
class="absolute right-0 top-0 minimize-form-button" class="absolute right-0 top-0 minimize-form-button"
:class="currentField?.description?.length > 100 ? 'mr-1 mt-1 md:mr-2 md:mt-2': 'mr-2 mt-2 hidden md:block'" :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')"
:aria-label="t('minimize')"
@click.prevent="minimizeForm" @click.prevent="minimizeForm"
> >
<IconArrowsDiagonalMinimize2 <IconArrowsDiagonalMinimize2
:width="20" :width="20"
:height="20" :height="20"
aria-hidden="true"
/> />
</button> </button>
<div <div
@@ -193,6 +245,7 @@
/> />
<div <div
v-if="currentField.description" v-if="currentField.description"
:id="currentField.uuid + '-desc'"
dir="auto" dir="auto"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
> >
@@ -203,6 +256,7 @@
:id="currentField.uuid" :id="currentField.uuid"
dir="auto" dir="auto"
:required="currentField.required" :required="currentField.required"
:aria-describedby="currentField.description ? currentField.uuid + '-desc' : undefined"
class="select base-input !text-2xl w-full text-center font-normal" class="select base-input !text-2xl w-full text-center font-normal"
:class="{ 'text-gray-300': !values[currentField.uuid] }" :class="{ 'text-gray-300': !values[currentField.uuid] }"
:name="`values[${currentField.uuid}]`" :name="`values[${currentField.uuid}]`"
@@ -250,6 +304,7 @@
</label> </label>
<div <div
v-if="currentField.description" v-if="currentField.description"
:id="currentField.uuid + '-desc'"
dir="auto" dir="auto"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
> >
@@ -309,6 +364,7 @@
> >
<div <div
v-if="currentField.description" v-if="currentField.description"
:id="currentField.uuid + '-desc'"
dir="auto" dir="auto"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
> >
@@ -511,6 +567,7 @@
<IconInnerShadowTop <IconInnerShadowTop
v-if="isSubmitting" v-if="isSubmitting"
class="mr-1 animate-spin" class="mr-1 animate-spin"
aria-hidden="true"
/> />
<span> <span>
{{ submitButtonText }} {{ submitButtonText }}
@@ -522,6 +579,7 @@
</button> </button>
<div <div
v-if="showFillAllRequiredFields" v-if="showFillAllRequiredFields"
role="alert"
class="text-center mt-1" class="text-center mt-1"
> >
{{ t('please_fill_all_required_fields') }} {{ t('please_fill_all_required_fields') }}
@@ -554,8 +612,10 @@
:can-send-email="canSendEmail && !!submitter.email" :can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
/> />
<div <nav
v-if="stepFields.length < 80" v-if="stepFields.length < 80"
:aria-label="t('form_progress')"
:aria-hidden="isCompleted"
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none" class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none"
> >
<div class="flex items-center flex-wrap steps-progress"> <div class="flex items-center flex-wrap steps-progress">
@@ -563,16 +623,18 @@
v-for="(step, index) in stepFields" v-for="(step, index) in stepFields"
:key="step[0].uuid" :key="step[0].uuid"
> >
<a <button
v-if="!onlyRequiredFields || step.some((f) => f.required)" v-if="!onlyRequiredFields || step.some((f) => f.required)"
href="#" type="button"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1" :aria-label="`${t('step')} ${index + 1}`"
:aria-current="index === currentStep ? 'step' : undefined"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1 p-0"
:class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }" :class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(index, true)]" @click="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
/> />
</template> </template>
</div> </div>
</div> </nav>
<div <div
v-else v-else
class="mt-5" class="mt-5"
@@ -584,6 +646,7 @@
<script> <script>
import FieldAreas from './areas' import FieldAreas from './areas'
import FormulaFieldAreas from './formula_areas' import FormulaFieldAreas from './formula_areas'
import AccessibilityAreas from './accessibility_areas'
import ImageStep from './image_step' import ImageStep from './image_step'
import SignatureStep from './signature_step' import SignatureStep from './signature_step'
import InitialsStep from './initials_step' import InitialsStep from './initials_step'
@@ -642,6 +705,7 @@ export default {
name: 'SubmissionForm', name: 'SubmissionForm',
components: { components: {
FieldAreas, FieldAreas,
AccessibilityAreas,
ImageStep, ImageStep,
SignatureStep, SignatureStep,
AppearsOn, AppearsOn,
@@ -824,6 +888,16 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
filledFieldsIndex: {
type: Object,
required: false,
default: null
},
withAccessibilityAreas: {
type: Boolean,
required: false,
default: null
},
fields: { fields: {
type: Array, type: Array,
required: false, required: false,
@@ -934,7 +1008,8 @@ export default {
isSubmitting: false, isSubmitting: false,
isSubmittingComplete: false, isSubmittingComplete: false,
submittedValues: {}, submittedValues: {},
recalculateButtonDisabledKey: '' recalculateButtonDisabledKey: '',
isAccessibilityMode: false
} }
}, },
computed: { computed: {
@@ -1458,7 +1533,7 @@ export default {
} }
this.enableScrollIntoField = false this.enableScrollIntoField = false
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus() this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], input[type="tel"], textarea, select')?.focus()
this.enableScrollIntoField = true this.enableScrollIntoField = true
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) { if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
@@ -1629,6 +1704,15 @@ export default {
if (this.completedRedirectUrl) { if (this.completedRedirectUrl) {
window.location.href = sanitizeUrl(this.completedRedirectUrl) window.location.href = sanitizeUrl(this.completedRedirectUrl)
} else {
this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode()
const completedEl = root.getElementById('form_completed')
if (completedEl) {
completedEl.focus()
}
})
} }
} }
} }
+98 -14
View File
@@ -1,4 +1,9 @@
const en = { const en = {
step: 'Step',
form_progress: 'Form progress',
close: 'Close',
uploaded_files: 'Uploaded files',
signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Please upload an image file', please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters long', must_be_characters_length: 'Must be {number} characters long',
@@ -98,10 +103,16 @@ const en = {
files: 'Files', files: 'Files',
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.', signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
browser_privacy_settings_block_canvas: 'Your browser privacy settings restrict use of the drawing canvas. Please use a different browser or device, or disable privacy settings that block canvas in order to sign.', browser_privacy_settings_block_canvas: 'Your browser privacy settings restrict use of the drawing canvas. Please use a different browser or device, or disable privacy settings that block canvas in order to sign.',
wait_countdown_seconds: 'Wait {countdown} seconds' wait_countdown_seconds: 'Wait {countdown} seconds',
enter_screen_reader_mode: 'Enter screen reader mode'
} }
const es = { const es = {
step: 'Paso',
form_progress: 'Progreso del formulario',
close: 'Cerrar',
uploaded_files: 'Archivos subidos',
signature_drawing_area: 'Área de dibujo de firma. Use el ratón o toque para dibujar su firma.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Por favor, sube un archivo de imagen', please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
must_be_characters_length: 'Debe tener {number} caracteres de longitud', must_be_characters_length: 'Debe tener {number} caracteres de longitud',
@@ -201,10 +212,16 @@ const es = {
files: 'Archivos', files: 'Archivos',
signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.', signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
browser_privacy_settings_block_canvas: 'La configuración de privacidad de su navegador restringe el uso del lienzo de dibujo. Utilice un navegador o dispositivo diferente, o desactive la configuración de privacidad que bloquea el lienzo para firmar.', browser_privacy_settings_block_canvas: 'La configuración de privacidad de su navegador restringe el uso del lienzo de dibujo. Utilice un navegador o dispositivo diferente, o desactive la configuración de privacidad que bloquea el lienzo para firmar.',
wait_countdown_seconds: 'Espera {countdown} segundos' wait_countdown_seconds: 'Espera {countdown} segundos',
enter_screen_reader_mode: 'Activar modo lector de pantalla'
} }
const it = { const it = {
step: 'Passo',
form_progress: 'Progresso del modulo',
close: 'Chiudi',
uploaded_files: 'File caricati',
signature_drawing_area: 'Area di disegno della firma. Usa il mouse o il tocco per disegnare la tua firma.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Per favore carica un file immagine', please_upload_an_image_file: 'Per favore carica un file immagine',
must_be_characters_length: 'Deve essere lungo {number} caratteri', must_be_characters_length: 'Deve essere lungo {number} caratteri',
@@ -304,10 +321,16 @@ const it = {
files: 'File', files: 'File',
signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.', signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
browser_privacy_settings_block_canvas: 'Le impostazioni sulla privacy del browser limitano l\'uso dell\'area di disegno. Utilizza un browser o dispositivo diverso oppure disattiva le impostazioni sulla privacy che bloccano il canvas per firmare.', browser_privacy_settings_block_canvas: 'Le impostazioni sulla privacy del browser limitano l\'uso dell\'area di disegno. Utilizza un browser o dispositivo diverso oppure disattiva le impostazioni sulla privacy che bloccano il canvas per firmare.',
wait_countdown_seconds: 'Attendi {countdown} secondi' wait_countdown_seconds: 'Attendi {countdown} secondi',
enter_screen_reader_mode: 'Attiva modalità lettore di schermo'
} }
const de = { const de = {
step: 'Schritt',
form_progress: 'Formularfortschritt',
close: 'Schließen',
uploaded_files: 'Hochgeladene Dateien',
signature_drawing_area: 'Unterschriftszeichenbereich. Verwenden Sie die Maus oder Berührung, um Ihre Unterschrift zu zeichnen.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch', please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein', must_be_characters_length: 'Muss {number} Zeichen lang sein',
@@ -407,10 +430,16 @@ const de = {
files: 'Dateien', files: 'Dateien',
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.', signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.',
browser_privacy_settings_block_canvas: 'Die Datenschutzeinstellungen Ihres Browsers schränken die Nutzung der Zeichenfläche ein. Bitte verwenden Sie einen anderen Browser oder ein anderes Gerät oder deaktivieren Sie die Datenschutzeinstellungen, die Canvas blockieren, um zu unterschreiben.', browser_privacy_settings_block_canvas: 'Die Datenschutzeinstellungen Ihres Browsers schränken die Nutzung der Zeichenfläche ein. Bitte verwenden Sie einen anderen Browser oder ein anderes Gerät oder deaktivieren Sie die Datenschutzeinstellungen, die Canvas blockieren, um zu unterschreiben.',
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten' wait_countdown_seconds: 'Bitte {countdown} Sekunden warten',
enter_screen_reader_mode: 'Screenreader-Modus aktivieren'
} }
const fr = { const fr = {
step: 'Étape',
form_progress: 'Progression du formulaire',
close: 'Fermer',
uploaded_files: 'Fichiers téléchargés',
signature_drawing_area: 'Zone de dessin de signature. Utilisez la souris ou le toucher pour dessiner votre signature.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Veuillez téléverser un fichier image', please_upload_an_image_file: 'Veuillez téléverser un fichier image',
must_be_characters_length: 'Doit comporter {number} caractères', must_be_characters_length: 'Doit comporter {number} caractères',
@@ -510,10 +539,16 @@ const fr = {
files: 'Fichiers', files: 'Fichiers',
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.', signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
browser_privacy_settings_block_canvas: 'Les paramètres de confidentialité de votre navigateur empêchent l\'utilisation du canevas de dessin. Veuillez utiliser un autre navigateur ou appareil, ou désactiver les paramètres de confidentialité qui bloquent le canevas pour signer.', browser_privacy_settings_block_canvas: 'Les paramètres de confidentialité de votre navigateur empêchent l\'utilisation du canevas de dessin. Veuillez utiliser un autre navigateur ou appareil, ou désactiver les paramètres de confidentialité qui bloquent le canevas pour signer.',
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes' wait_countdown_seconds: 'Veuillez patienter {countdown} secondes',
enter_screen_reader_mode: 'Activer le mode lecteur d\'écran'
} }
const pl = { const pl = {
step: 'Krok',
form_progress: 'Postęp formularza',
close: 'Zamknij',
uploaded_files: 'Przesłane pliki',
signature_drawing_area: 'Obszar rysowania podpisu. Użyj myszy lub dotyku, aby narysować swój podpis.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Proszę przesłać plik obrazu', please_upload_an_image_file: 'Proszę przesłać plik obrazu',
must_be_characters_length: 'Musi mieć długość {number} znaków', must_be_characters_length: 'Musi mieć długość {number} znaków',
@@ -613,10 +648,16 @@ const pl = {
files: 'Pliki', files: 'Pliki',
signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.', signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
browser_privacy_settings_block_canvas: 'Ustawienia prywatności przeglądarki blokują użycie obszaru rysowania. Użyj innej przeglądarki lub urządzenia albo wyłącz ustawienia prywatności blokujące canvas, aby podpisać.', browser_privacy_settings_block_canvas: 'Ustawienia prywatności przeglądarki blokują użycie obszaru rysowania. Użyj innej przeglądarki lub urządzenia albo wyłącz ustawienia prywatności blokujące canvas, aby podpisać.',
wait_countdown_seconds: 'Poczekaj {countdown} sekund' wait_countdown_seconds: 'Poczekaj {countdown} sekund',
enter_screen_reader_mode: 'Włącz tryb czytnika ekranu'
} }
const uk = { const uk = {
step: 'Крок',
form_progress: 'Прогрес форми',
close: 'Закрити',
uploaded_files: 'Завантажені файли',
signature_drawing_area: 'Область малювання підпису. Використовуйте мишу або дотик, щоб намалювати свій підпис.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення', please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів', must_be_characters_length: 'Має містити {number} символів',
@@ -716,10 +757,16 @@ const uk = {
files: 'Файли', files: 'Файли',
signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.', signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
browser_privacy_settings_block_canvas: 'Налаштування конфіденційності вашого браузера блокують використання полотна для малювання. Будь ласка, скористайтеся іншим браузером або пристроєм, або вимкніть налаштування конфіденційності, що блокують canvas, щоб підписати.', browser_privacy_settings_block_canvas: 'Налаштування конфіденційності вашого браузера блокують використання полотна для малювання. Будь ласка, скористайтеся іншим браузером або пристроєм, або вимкніть налаштування конфіденційності, що блокують canvas, щоб підписати.',
wait_countdown_seconds: 'Зачекайте {countdown} секунд' wait_countdown_seconds: 'Зачекайте {countdown} секунд',
enter_screen_reader_mode: 'Увімкнути режим читання з екрану'
} }
const cs = { const cs = {
step: 'Krok',
form_progress: 'Průběh formuláře',
close: 'Zavřít',
uploaded_files: 'Nahrané soubory',
signature_drawing_area: 'Oblast pro kreslení podpisu. Použijte myš nebo dotyk k nakreslení podpisu.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor', please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
must_be_characters_length: 'Musí mít délku {number} znaků', must_be_characters_length: 'Musí mít délku {number} znaků',
@@ -819,10 +866,16 @@ const cs = {
files: 'Soubory', files: 'Soubory',
signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.', signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
browser_privacy_settings_block_canvas: 'Nastavení soukromí vašeho prohlížeče omezuje použití kreslicího plátna. Použijte prosím jiný prohlížeč nebo zařízení, nebo vypněte nastavení soukromí blokující canvas pro podepsání.', browser_privacy_settings_block_canvas: 'Nastavení soukromí vašeho prohlížeče omezuje použití kreslicího plátna. Použijte prosím jiný prohlížeč nebo zařízení, nebo vypněte nastavení soukromí blokující canvas pro podepsání.',
wait_countdown_seconds: 'Počkejte {countdown} sekund' wait_countdown_seconds: 'Počkejte {countdown} sekund',
enter_screen_reader_mode: 'Zapnout režim čtečky obrazovky'
} }
const pt = { const pt = {
step: 'Passo',
form_progress: 'Progresso do formulário',
close: 'Fechar',
uploaded_files: 'Arquivos enviados',
signature_drawing_area: 'Área de desenho da assinatura. Use o mouse ou toque para desenhar sua assinatura.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem', please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
must_be_characters_length: 'Deve ter {number} caracteres', must_be_characters_length: 'Deve ter {number} caracteres',
@@ -922,10 +975,16 @@ const pt = {
files: 'Arquivos', files: 'Arquivos',
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.', signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
browser_privacy_settings_block_canvas: 'As configurações de privacidade do seu navegador restringem o uso da área de desenho. Use um navegador ou dispositivo diferente, ou desative as configurações de privacidade que bloqueiam o canvas para assinar.', browser_privacy_settings_block_canvas: 'As configurações de privacidade do seu navegador restringem o uso da área de desenho. Use um navegador ou dispositivo diferente, ou desative as configurações de privacidade que bloqueiam o canvas para assinar.',
wait_countdown_seconds: 'Aguarde {countdown} segundos' wait_countdown_seconds: 'Aguarde {countdown} segundos',
enter_screen_reader_mode: 'Ativar modo leitor de tela'
} }
const he = { const he = {
step: 'שלב',
form_progress: 'התקדמות הטופס',
close: 'סגור',
uploaded_files: 'קבצים שהועלו',
signature_drawing_area: 'אזור ציור חתימה. השתמש בעכבר או במגע כדי לצייר את החתימה שלך.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'אנא העלה קובץ תמונה', please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים', must_be_characters_length: 'חייב להיות באורך של {number} תווים',
@@ -1025,10 +1084,16 @@ const he = {
files: 'קבצים', files: 'קבצים',
signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.', signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
browser_privacy_settings_block_canvas: 'הגדרות הפרטיות של הדפדפן שלך מגבילות את השימוש באזור הציור. אנא השתמש בדפדפן או מכשיר אחר, או בטל את הגדרות הפרטיות החוסמות canvas כדי לחתום.', browser_privacy_settings_block_canvas: 'הגדרות הפרטיות של הדפדפן שלך מגבילות את השימוש באזור הציור. אנא השתמש בדפדפן או מכשיר אחר, או בטל את הגדרות הפרטיות החוסמות canvas כדי לחתום.',
wait_countdown_seconds: 'המתן {countdown} שניות' wait_countdown_seconds: 'המתן {countdown} שניות',
enter_screen_reader_mode: 'הפעל מצב קורא מסך'
} }
const nl = { const nl = {
step: 'Stap',
form_progress: 'Formuliervoortgang',
close: 'Sluiten',
uploaded_files: 'Geüploade bestanden',
signature_drawing_area: 'Handtekening tekengebied. Gebruik de muis of aanraking om uw handtekening te tekenen.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand', please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn', must_be_characters_length: 'Moet {number} tekens lang zijn',
@@ -1128,10 +1193,16 @@ const nl = {
files: 'Bestanden', files: 'Bestanden',
signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.', signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
browser_privacy_settings_block_canvas: 'De privacyinstellingen van uw browser beperken het gebruik van het tekenveld. Gebruik een andere browser of ander apparaat, of schakel de privacyinstellingen uit die canvas blokkeren om te ondertekenen.', browser_privacy_settings_block_canvas: 'De privacyinstellingen van uw browser beperken het gebruik van het tekenveld. Gebruik een andere browser of ander apparaat, of schakel de privacyinstellingen uit die canvas blokkeren om te ondertekenen.',
wait_countdown_seconds: 'Wacht {countdown} seconden' wait_countdown_seconds: 'Wacht {countdown} seconden',
enter_screen_reader_mode: 'Schermlezer-modus inschakelen'
} }
const ar = { const ar = {
step: 'خطوة',
form_progress: 'تقدم النموذج',
close: 'إغلاق',
uploaded_files: 'الملفات المرفوعة',
signature_drawing_area: 'منطقة رسم التوقيع. استخدم الماوس أو اللمس لرسم توقيعك.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: 'يرجى تحميل ملف صورة', please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا', must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
@@ -1231,10 +1302,16 @@ const ar = {
files: 'الملفات', files: 'الملفات',
signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.', signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
browser_privacy_settings_block_canvas: 'إعدادات الخصوصية في متصفحك تمنع استخدام لوحة الرسم. يرجى استخدام متصفح أو جهاز مختلف، أو تعطيل إعدادات الخصوصية التي تحظر canvas للتوقيع.', browser_privacy_settings_block_canvas: 'إعدادات الخصوصية في متصفحك تمنع استخدام لوحة الرسم. يرجى استخدام متصفح أو جهاز مختلف، أو تعطيل إعدادات الخصوصية التي تحظر canvas للتوقيع.',
wait_countdown_seconds: 'انتظر {countdown} ثانية' wait_countdown_seconds: 'انتظر {countdown} ثانية',
enter_screen_reader_mode: 'تفعيل وضع قارئ الشاشة'
} }
const ko = { const ko = {
step: '단계',
form_progress: '양식 진행',
close: '닫기',
uploaded_files: '업로드된 파일',
signature_drawing_area: '서명 그리기 영역. 마우스 또는 터치를 사용하여 서명을 그리세요.',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: '이미지 파일을 업로드해 주세요', please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다', must_be_characters_length: '{number}자여야 합니다',
@@ -1334,10 +1411,16 @@ const ko = {
files: '파일', files: '파일',
signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.', signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
browser_privacy_settings_block_canvas: '브라우저 개인정보 보호 설정으로 인해 그리기 캔버스를 사용할 수 없습니다. 다른 브라우저나 기기를 사용하거나, 서명을 위해 캔버스를 차단하는 개인정보 보호 설정을 비활성화해 주세요.', browser_privacy_settings_block_canvas: '브라우저 개인정보 보호 설정으로 인해 그리기 캔버스를 사용할 수 없습니다. 다른 브라우저나 기기를 사용하거나, 서명을 위해 캔버스를 차단하는 개인정보 보호 설정을 비활성화해 주세요.',
wait_countdown_seconds: '{countdown}초 기다리세요' wait_countdown_seconds: '{countdown}초 기다리세요',
enter_screen_reader_mode: '스크린 리더 모드 활성화'
} }
const ja = { const ja = {
step: 'ステップ',
form_progress: 'フォームの進捗',
close: '閉じる',
uploaded_files: 'アップロードされたファイル',
signature_drawing_area: '署名描画エリア。マウスまたはタッチを使用して署名を描いてください。',
kba: 'KBA', kba: 'KBA',
please_upload_an_image_file: '画像ファイルをアップロードしてください', please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません', must_be_characters_length: '{number}文字でなければなりません',
@@ -1437,7 +1520,8 @@ const ja = {
files: 'ファイル', files: 'ファイル',
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。', signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
browser_privacy_settings_block_canvas: 'ブラウザのプライバシー設定により、描画キャンバスの使用が制限されています。別のブラウザまたはデバイスを使用するか、署名するためにキャンバスをブロックするプライバシー設定を無効にしてください。', browser_privacy_settings_block_canvas: 'ブラウザのプライバシー設定により、描画キャンバスの使用が制限されています。別のブラウザまたはデバイスを使用するか、署名するためにキャンバスをブロックするプライバシー設定を無効にしてください。',
wait_countdown_seconds: '{countdown} 秒お待ちください' wait_countdown_seconds: '{countdown} 秒お待ちください',
enter_screen_reader_mode: 'スクリーンリーダーモードを有効にする'
} }
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja } const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja }
@@ -18,13 +18,17 @@
class="btn btn-outline btn-sm reupload-button" class="btn btn-outline btn-sm reupload-button"
@click.prevent="remove" @click.prevent="remove"
> >
<IconReload :width="16" /> <IconReload
:width="16"
aria-hidden="true"
/>
{{ t('reupload') }} {{ t('reupload') }}
</button> </button>
</div> </div>
<div> <div>
<img <img
:src="attachmentsIndex[modelValue].url" :src="attachmentsIndex[modelValue].url"
:alt="field.name || t('image')"
class="h-52 border border-base-300 rounded mx-auto uploaded-image-preview" class="h-52 border border-base-300 rounded mx-auto uploaded-image-preview"
> >
</div> </div>
@@ -22,43 +22,60 @@
class="md:tooltip" class="md:tooltip"
:data-tip="t('type_initial')" :data-tip="t('type_initial')"
> >
<a <button
id="type_text_button" id="type_text_button"
href="#" type="button"
:aria-label="t('type_initial')"
class="btn btn-outline font-medium btn-sm type-text-button" class="btn btn-outline font-medium btn-sm type-text-button"
@click.prevent="toggleTextInput" @click="toggleTextInput"
> >
<IconTextSize :width="16" /> <IconTextSize
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('type') }} {{ t('type') }}
</span> </span>
</a> </button>
</span> </span>
<span <span
v-else v-else
class="md:tooltip ml-2" class="md:tooltip ml-2"
:data-tip="t('draw_initials')" :data-tip="t('draw_initials')"
> >
<a <button
id="type_text_button" id="type_text_button"
href="#" type="button"
:aria-label="t('draw_initials')"
class="btn btn-outline font-medium btn-sm type-text-button" class="btn btn-outline font-medium btn-sm type-text-button"
@click.prevent="toggleTextInput" @click="toggleTextInput"
> >
<IconSignature :width="16" /> <IconSignature
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('draw') }} {{ t('draw') }}
</span> </span>
</a> </button>
</span> </span>
<span <span
class="md:tooltip" class="md:tooltip"
:data-tip="t('click_to_upload')" :data-tip="t('click_to_upload')"
> >
<label class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"> <button
<IconUpload :width="16" /> type="button"
:aria-label="t('click_to_upload')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
@click="$refs.uploadInput.click()"
>
<IconUpload
:width="16"
aria-hidden="true"
/>
<input <input
:key="uploadImageInputKey" :key="uploadImageInputKey"
ref="uploadInput"
type="file" type="file"
hidden hidden
accept="image/*" accept="image/*"
@@ -67,37 +84,45 @@
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('upload') }} {{ t('upload') }}
</span> </span>
</label> </button>
</span> </span>
<a <button
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
href="#" type="button"
class="btn font-medium btn-outline btn-sm clear-canvas-button" class="btn font-medium btn-outline btn-sm clear-canvas-button"
@click.prevent="remove" @click="remove"
> >
<IconReload :width="16" /> <IconReload
:width="16"
aria-hidden="true"
/>
{{ t('clear') }} {{ t('clear') }}
</a> </button>
<a <button
v-else v-else
href="#" type="button"
class="btn font-medium btn-outline btn-sm clear-canvas-button" class="btn font-medium btn-outline btn-sm clear-canvas-button"
@click.prevent="clear" @click="clear"
> >
<IconReload :width="16" /> <IconReload
:width="16"
aria-hidden="true"
/>
{{ t('clear') }} {{ t('clear') }}
</a> </button>
<a <button
type="button"
:title="t('minimize')" :title="t('minimize')"
href="#" :aria-label="t('minimize')"
class="py-1.5 inline md:hidden" class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')" @click="$emit('minimize')"
> >
<IconArrowsDiagonalMinimize2 <IconArrowsDiagonalMinimize2
:width="20" :width="20"
:height="20" :height="20"
aria-hidden="true"
/> />
</a> </button>
</div> </div>
</div> </div>
<div <div
@@ -19,6 +19,7 @@
</label> </label>
<div <div
v-if="field.description" v-if="field.description"
:id="field.uuid + '-desc'"
dir="auto" dir="auto"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
> >
@@ -59,6 +60,7 @@
type="checkbox" type="checkbox"
:name="`values[${field.uuid}][]`" :name="`values[${field.uuid}][]`"
:value="optionValue(option, index)" :value="optionValue(option, index)"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
class="base-checkbox !h-7 !w-7" class="base-checkbox !h-7 !w-7"
:checked="(modelValue || []).includes(optionValue(option, index))" :checked="(modelValue || []).includes(optionValue(option, index))"
@change="onChange" @change="onChange"
@@ -23,6 +23,7 @@
/> />
<div <div
v-if="field.description" v-if="field.description"
:id="field.uuid + '-desc'"
dir="auto" dir="auto"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
> >
@@ -44,6 +45,7 @@
:max="field.validation?.max" :max="field.validation?.max"
class="base-input !text-2xl w-full" class="base-input !text-2xl w-full"
:required="field.required" :required="field.required"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`" :placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@focus="$emit('focus')" @focus="$emit('focus')"
+10 -8
View File
@@ -16,6 +16,7 @@
</label> </label>
<div <div
v-if="field.description" v-if="field.description"
:id="field.uuid + '-desc'"
dir="auto" dir="auto"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
> >
@@ -42,28 +43,28 @@
@input="onInputCode" @input="onInputCode"
> >
<div class="flex justify-between mt-2 -mb-2 md:-mb-4"> <div class="flex justify-between mt-2 -mb-2 md:-mb-4">
<a <button
v-if="!defaultValue" v-if="!defaultValue"
href="#" type="button"
class="link change-phone-number-link" class="link change-phone-number-link"
@click.prevent="isCodeSent = false" @click="isCodeSent = false"
> >
{{ t('change_phone_number') }} {{ t('change_phone_number') }}
</a> </button>
<span <span
v-if="resendCodeCountdown > 0" v-if="resendCodeCountdown > 0"
class="link" class="link"
> >
{{ t('wait_countdown_seconds').replace('{countdown}', resendCodeCountdown) }} {{ t('wait_countdown_seconds').replace('{countdown}', resendCodeCountdown) }}
</span> </span>
<a <button
v-else v-else
href="#" type="button"
class="link resend-code-link" class="link resend-code-link"
@click.prevent="resendCode" @click="resendCode"
> >
{{ isResendLoading ? t('sending') : t('resend_code') }} {{ isResendLoading ? t('sending') : t('resend_code') }}
</a> </button>
</div> </div>
</div> </div>
<div <div
@@ -107,6 +108,7 @@
type="tel" type="tel"
inputmode="tel" inputmode="tel"
:required="field.required" :required="field.required"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
placeholder="234 567-8900" placeholder="234 567-8900"
@input="onPhoneInput" @input="onPhoneInput"
@focus="$emit('focus')" @focus="$emit('focus')"
@@ -25,17 +25,21 @@
class="md:tooltip" class="md:tooltip"
:data-tip="t('draw_signature')" :data-tip="t('draw_signature')"
> >
<a <button
id="type_text_button" id="type_text_button"
href="#" type="button"
:aria-label="t('draw_signature')"
class="btn btn-outline btn-sm font-medium type-text-button" class="btn btn-outline btn-sm font-medium type-text-button"
@click.prevent="[toggleTextInput(), hideQr()]" @click="[toggleTextInput(), hideQr()]"
> >
<IconSignature :width="16" /> <IconSignature
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('draw') }} {{ t('draw') }}
</span> </span>
</a> </button>
</span> </span>
<span <span
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'" v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
@@ -43,17 +47,21 @@
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }" :class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('type_text')" :data-tip="t('type_text')"
> >
<a <button
id="type_text_button" id="type_text_button"
href="#" type="button"
:aria-label="t('type_text')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button"
@click.prevent="[toggleTextInput(), hideQr()]" @click="[toggleTextInput(), hideQr()]"
> >
<IconTextSize :width="16" /> <IconTextSize
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('type') }} {{ t('type') }}
</span> </span>
</a> </button>
</span> </span>
<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'"
@@ -61,10 +69,19 @@
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }" :class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('take_photo')" :data-tip="t('take_photo')"
> >
<label class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"> <button
<IconCamera :width="16" /> type="button"
:aria-label="t('take_photo')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
@click="$refs.takePhotoInput.click()"
>
<IconCamera
:width="16"
aria-hidden="true"
/>
<input <input
:key="uploadImageInputKey" :key="uploadImageInputKey"
ref="takePhotoInput"
type="file" type="file"
hidden hidden
accept="image/*" accept="image/*"
@@ -73,49 +90,57 @@
<span class="hidden sm:inline"> <span class="hidden sm:inline">
{{ t('upload') }} {{ t('upload') }}
</span> </span>
</label> </button>
</span> </span>
<a <button
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
href="#" type="button"
class="btn btn-outline btn-sm font-medium reupload-button" class="btn btn-outline btn-sm font-medium reupload-button"
@click.prevent="remove" @click="remove"
> >
<IconReload :width="16" /> <IconReload
:width="16"
aria-hidden="true"
/>
{{ t(format === 'upload' ? 'reupload' : 'redraw') }} {{ t(format === 'upload' ? 'reupload' : 'redraw') }}
</a> </button>
<span <span
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'" v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
class="md:tooltip before:translate-x-[-90%]" class="md:tooltip before:translate-x-[-90%]"
:data-tip="t('sign_on_the_touchscreen')" :data-tip="t('sign_on_the_touchscreen')"
> >
<a <button
href="#" type="button"
:aria-label="t('sign_on_the_touchscreen')"
class="btn btn-sm btn-neutral font-medium hidden md:flex" class="btn btn-sm btn-neutral font-medium hidden md:flex"
:class="{ 'btn-outline': !isShowQr, 'text-white': isShowQr }" :class="{ 'btn-outline': !isShowQr, 'text-white': isShowQr }"
@click.prevent="isShowQr ? hideQr() : [isTextSignature = false, showQr()]" @click="isShowQr ? hideQr() : [isTextSignature = false, showQr()]"
> >
<IconQrcode <IconQrcode
:width="19" :width="19"
:height="19" :height="19"
aria-hidden="true"
/> />
</a> </button>
</span> </span>
<a <button
href="#" type="button"
:title="t('minimize')" :title="t('minimize')"
:aria-label="t('minimize')"
class="py-1.5 inline md:hidden" class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')" @click="$emit('minimize')"
> >
<IconArrowsDiagonalMinimize2 <IconArrowsDiagonalMinimize2
:width="20" :width="20"
:height="20" :height="20"
aria-hidden="true"
/> />
</a> </button>
</div> </div>
</div> </div>
<div <div
v-if="field.description" v-if="field.description"
:id="field.uuid + '-desc'"
dir="auto" dir="auto"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
> >
@@ -136,6 +161,7 @@
<img <img
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url" :src="attachmentsIndex[modelValue || computedPreviousValue].url"
:alt="field.name || t('signature')"
class="mx-auto bg-white border border-base-300 rounded max-h-44" class="mx-auto bg-white border border-base-300 rounded max-h-44"
> >
<FileDropzone <FileDropzone
@@ -154,14 +180,17 @@
v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted" v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted"
class="absolute top-0.5 right-0.5" class="absolute top-0.5 right-0.5"
> >
<a <button
href="#" type="button"
class="btn btn-ghost font-medium btn-xs md:btn-sm" class="btn btn-ghost font-medium btn-xs md:btn-sm"
@click.prevent="[clear(), hideQr()]" @click="[clear(), hideQr()]"
> >
<IconReload :width="16" /> <IconReload
:width="16"
aria-hidden="true"
/>
{{ t('clear') }} {{ t('clear') }}
</a> </button>
</div> </div>
<div <div
v-if="isTextSignature" v-if="isTextSignature"
@@ -170,6 +199,8 @@
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
role="application"
:aria-label="t('signature_drawing_area')"
style="padding: 1px; 0" style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas" class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
/> />
@@ -182,13 +213,14 @@
class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl" class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl"
> >
<div class="absolute top-1.5 right-1.5"> <div class="absolute top-1.5 right-1.5">
<a <button
href="#" type="button"
class="btn btn-sm btn-circle btn-normal btn-outline" class="btn btn-sm btn-circle btn-normal btn-outline"
@click.prevent="hideQr" :aria-label="t('close')"
@click="hideQr"
> >
<IconX /> <IconX aria-hidden="true" />
</a> </button>
</div> </div>
<div class="flex items-center justify-center w-full h-full p-4"> <div class="flex items-center justify-center w-full h-full p-4">
<div <div
@@ -512,6 +544,7 @@ export default {
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!this.$refs.canvas) return if (!this.$refs.canvas) return
if (!this.$refs.canvas.parentNode?.clientWidth) return
const { width, height } = this.$refs.canvas const { width, height } = this.$refs.canvas
@@ -554,7 +587,7 @@ export default {
redrawCanvas (oldWidth, oldHeight) { redrawCanvas (oldWidth, oldHeight) {
const canvas = this.$refs.canvas const canvas = this.$refs.canvas
if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) { if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && canvas.width > 0 && canvas.height > 0) {
const sx = canvas.width / oldWidth const sx = canvas.width / oldWidth
const sy = canvas.height / oldHeight const sy = canvas.height / oldHeight
+9 -5
View File
@@ -23,6 +23,7 @@
/> />
<div <div
v-if="field.description" v-if="field.description"
:id="field.uuid + '-desc'"
dir="auto" dir="auto"
class="mb-3 px-1 field-description-text" class="mb-3 px-1 field-description-text"
> >
@@ -40,6 +41,7 @@
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }" :class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
:required="field.required" :required="field.required"
:pattern="field.validation?.pattern" :pattern="field.validation?.pattern"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`" :placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text" type="text"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@@ -54,6 +56,7 @@
v-model="text" v-model="text"
dir="auto" dir="auto"
class="base-textarea !text-2xl w-full" class="base-textarea !text-2xl w-full"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`" :placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
:required="field.required" :required="field.required"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@@ -65,13 +68,14 @@
class="tooltip" class="tooltip"
:data-tip="t('toggle_multiline_text')" :data-tip="t('toggle_multiline_text')"
> >
<a <button
href="#" type="button"
:aria-label="t('toggle_multiline_text')"
class="btn btn-ghost btn-circle btn-sm toggle-multiline-text-button" class="btn btn-ghost btn-circle btn-sm toggle-multiline-text-button"
@click.prevent="toggleTextArea" @click="toggleTextArea"
> >
<IconAlignBoxLeftTop /> <IconAlignBoxLeftTop aria-hidden="true" />
</a> </button>
</div> </div>
</div> </div>
</template> </template>
@@ -30,6 +30,7 @@
<IconInnerShadowTop <IconInnerShadowTop
width="40" width="40"
class="animate-spin h-10" class="animate-spin h-10"
aria-hidden="true"
/> />
</div> </div>
<div v-else-if="redirectUrl"> <div v-else-if="redirectUrl">
+48 -8
View File
@@ -113,7 +113,7 @@
<div <div
ref="textContainer" ref="textContainer"
class="flex items-center px-0.5" class="flex items-center px-0.5"
:style="{ color: field.preferences?.color }" :style="{ color: isConditionMatch ? field.preferences?.color : '#9ca3af' }"
:class="{ 'w-full h-full': isWFullType }" :class="{ 'w-full h-full': isWFullType }"
> >
<IconCheck <IconCheck
@@ -131,13 +131,13 @@
/> />
</template> </template>
<span <span
v-else-if="field.type === 'number' && !isValueInput && (field.default_value || field.default_value == 0)" v-else-if="field.type === 'number' && !isContenteditable && (displayValue || displayValue == 0)"
class="whitespace-pre-wrap" class="whitespace-pre-wrap"
>{{ formatNumber(field.default_value, field.preferences?.format) }}</span> >{{ formatNumber(displayValue, field.preferences?.format) }}</span>
<span <span
v-else-if="field.default_value === '{{date}}'" v-else-if="field.default_value === '{{date}}'"
> >
{{ t('signing_date') }} {{ /[HhAasz]/.test(field.preferences?.format || '') ? t('signing_date_and_time') : t('signing_date') }}
</span> </span>
<div <div
v-else-if="field.type === 'cells' && field.default_value" v-else-if="field.type === 'cells' && field.default_value"
@@ -183,12 +183,12 @@
:contenteditable="isValueInput" :contenteditable="isValueInput"
class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-base-content/30" class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-base-content/30"
:class="{ 'cursor-text': isValueInput }" :class="{ 'cursor-text': isValueInput }"
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))" :placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (isConditionMatch ? (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value')) : '')"
@blur="onDefaultValueBlur" @blur="onDefaultValueBlur"
@focus="selectedAreasRef.value = [area]" @focus="selectedAreasRef.value = [area]"
@paste.prevent="onPaste" @paste.prevent="onPaste"
@keydown.enter="onDefaultValueEnter" @keydown.enter="onDefaultValueEnter"
>{{ field.default_value }}</span> >{{ displayValue }}</span>
</div> </div>
</div> </div>
<component <component
@@ -241,6 +241,16 @@ export default {
required: false, required: false,
default: false default: false
}, },
conditionalFieldIndex: {
type: Object,
required: false,
default: () => ({})
},
formulaValuesIndex: {
type: Object,
required: false,
default: () => ({})
},
isDraw: { isDraw: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -323,11 +333,27 @@ export default {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels, fieldLabels: FieldType.computed.fieldLabels,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
isConditionMatch () {
return !this.inputMode || this.conditionalFieldIndex[this.field.uuid] !== false
},
displayValue () {
if (this.field.preferences?.formula && this.field.type !== 'payment') {
const computed = this.formulaValuesIndex[this.field.uuid]
if (computed != null) {
return computed
}
}
return this.field.default_value
},
bgClasses () { bgClasses () {
if (this.field.type === 'heading') { if (this.field.type === 'heading') {
return 'bg-gray-50' return 'bg-gray-50'
} else if (this.field.type === 'strikethrough') { } else if (this.field.type === 'strikethrough') {
return 'bg-transparent' return 'bg-transparent'
} else if (!this.isConditionMatch) {
return 'bg-gray-100'
} else { } else {
return this.bgColors[this.submitterIndex % this.bgColors.length] return this.bgColors[this.submitterIndex % this.bgColors.length]
} }
@@ -337,6 +363,8 @@ export default {
return '' return ''
} else if (this.field.type === 'strikethrough') { } else if (this.field.type === 'strikethrough') {
return 'border-dashed border-gray-300' return 'border-dashed border-gray-300'
} else if (!this.isConditionMatch) {
return 'border-gray-300'
} else { } else {
return this.borderColors[this.submitterIndex % this.borderColors.length] return this.borderColors[this.submitterIndex % this.borderColors.length]
} }
@@ -390,7 +418,7 @@ export default {
return this.basePageWidth / 612.0 return this.basePageWidth / 612.0
}, },
isDefaultValuePresent () { isDefaultValuePresent () {
return this.field?.default_value || this.field?.default_value === 0 return this.field?.default_value || this.field?.default_value === 0 || this.displayValue || this.displayValue === 0
}, },
isSelectInput () { isSelectInput () {
return this.inputMode && (this.field.type === 'select' || (this.field.type === 'radio' && this.field.areas?.length < 2)) return this.inputMode && (this.field.type === 'select' || (this.field.type === 'radio' && this.field.areas?.length < 2))
@@ -399,6 +427,8 @@ export default {
return this.inputMode && (this.field.type === 'checkbox' || (['radio', 'multiple'].includes(this.field.type) && this.area.option_uuid)) return this.inputMode && (this.field.type === 'checkbox' || (['radio', 'multiple'].includes(this.field.type) && this.area.option_uuid))
}, },
isValueInput () { isValueInput () {
if (this.inputMode && this.field.preferences?.formula) return false
return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable || return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable ||
(this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}'))) (this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}')))
}, },
@@ -511,7 +541,7 @@ export default {
return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}` return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
}, },
maybeToggleDefaultValue () { maybeToggleDefaultValue () {
if (!this.editable || this.isCmdKeyRef.value) { if (!this.editable || this.isCmdKeyRef.value || this.field.preferences?.formula) {
return return
} }
@@ -559,6 +589,10 @@ export default {
} }
}, },
focusValueInput (e) { focusValueInput (e) {
if (this.inputMode && this.field.type === 'number' && !this.isContenteditable && !this.field.preferences?.formula) {
this.isContenteditable = true
}
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.defaultValue && this.$refs.defaultValue !== document.activeElement) { if (this.$refs.defaultValue && this.$refs.defaultValue !== document.activeElement) {
this.$refs.defaultValue.focus() this.$refs.defaultValue.focus()
@@ -624,6 +658,12 @@ export default {
} }
}, },
onDefaultValueBlur (e) { onDefaultValueBlur (e) {
if (this.field.preferences?.formula) {
this.isContenteditable = false
return
}
const text = this.$refs.defaultValue.innerText.trim() const text = this.$refs.defaultValue.innerText.trim()
this.isContenteditable = false this.isContenteditable = false
+331 -47
View File
@@ -381,6 +381,9 @@
:document="document" :document="document"
:is-drag="!!dragField" :is-drag="!!dragField"
:input-mode="inputMode" :input-mode="inputMode"
:conditional-field-index="conditionalFieldIndex"
:formula-values-index="formulaValuesIndex"
:page-preview-format="pagePreviewFormat"
:default-fields="[...defaultRequiredFields, ...defaultFields]" :default-fields="[...defaultRequiredFields, ...defaultFields]"
:allow-draw="!onlyDefinedFields || drawField || drawCustomField" :allow-draw="!onlyDefinedFields || drawField || drawCustomField"
:with-signature-id="withSignatureId" :with-signature-id="withSignatureId"
@@ -504,11 +507,14 @@
:with-custom-fields="withCustomFields" :with-custom-fields="withCustomFields"
:with-fields-search="withFieldsSearch" :with-fields-search="withFieldsSearch"
:default-fields="[...defaultRequiredFields, ...defaultFields]" :default-fields="[...defaultRequiredFields, ...defaultFields]"
:with-custom-fields-tab="withCustomFieldsTab"
:template="template" :template="template"
:default-required-fields="defaultRequiredFields" :default-required-fields="defaultRequiredFields"
:detect-custom-fields-index="detectCustomFieldsIndex"
:field-types="fieldTypes" :field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters" :with-sticky-submitters="withStickySubmitters"
:with-fields-detection="withFieldsDetection" :with-fields-detection="withFieldsDetection"
:with-detect-existing-fields="withDetectExistingFields"
:with-signature-id="withSignatureId" :with-signature-id="withSignatureId"
:with-prefillable="withPrefillable" :with-prefillable="withPrefillable"
:only-defined-fields="onlyDefinedFields" :only-defined-fields="onlyDefinedFields"
@@ -617,6 +623,16 @@ import { v4 } from 'uuid'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue' import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n' import * as i18n from './i18n'
const isEmpty = (obj) => {
if (obj == null) return true
if (Array.isArray(obj)) return obj.length === 0
if (typeof obj === 'string') return obj.trim().length === 0
if (typeof obj === 'object') return Object.keys(obj).length === 0
if (obj === false) return true
return false
}
export default { export default {
name: 'TemplateBuilder', name: 'TemplateBuilder',
components: { components: {
@@ -654,6 +670,7 @@ export default {
locale: this.locale, locale: this.locale,
baseFetch: this.baseFetch, baseFetch: this.baseFetch,
fieldTypes: this.fieldTypes, fieldTypes: this.fieldTypes,
dateFormats: this.dateFormats,
backgroundColor: this.backgroundColor, backgroundColor: this.backgroundColor,
withPhone: this.withPhone, withPhone: this.withPhone,
withVerification: this.withVerification, withVerification: this.withVerification,
@@ -738,6 +755,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withDetectExistingFields: {
type: Boolean,
required: false,
default: false
},
withCustomFields: { withCustomFields: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -778,6 +800,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
withCustomFieldsTab: {
type: Boolean,
required: false,
default: false
},
withSelectedFieldType: { withSelectedFieldType: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -798,6 +825,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
dateFormats: {
type: Array,
required: false,
default: () => []
},
defaultSubmitters: { defaultSubmitters: {
type: Array, type: Array,
required: false, required: false,
@@ -808,6 +840,11 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
pagePreviewFormat: {
type: String,
required: false,
default: '.jpg'
},
acceptFileTypes: { acceptFileTypes: {
type: String, type: String,
required: false, required: false,
@@ -953,6 +990,7 @@ export default {
isLoadingBlankPage: false, isLoadingBlankPage: false,
isSaving: false, isSaving: false,
isDetectingPageFields: false, isDetectingPageFields: false,
detectFieldsQueue: [],
detectingAnalyzingProgress: null, detectingAnalyzingProgress: null,
detectingFieldsAddedCount: null, detectingFieldsAddedCount: null,
selectedSubmitter: null, selectedSubmitter: null,
@@ -963,7 +1001,8 @@ export default {
drawCustomField: null, drawCustomField: null,
drawOption: null, drawOption: null,
dragField: null, dragField: null,
isDragFile: false isDragFile: false,
isMathLoaded: false
} }
}, },
computed: { computed: {
@@ -973,6 +1012,13 @@ export default {
fieldsDragFieldRef: () => ref(), fieldsDragFieldRef: () => ref(),
customDragFieldRef: () => ref(), customDragFieldRef: () => ref(),
selectedAreasRef: () => ref([]), selectedAreasRef: () => ref([]),
attachmentUuidsIndex () {
return this.template.schema.reduce((acc, e, index) => {
acc[e.attachment_uuid] = index
return acc
}, {})
},
language () { language () {
return this.locale.split('-')[0].toLowerCase() return this.locale.split('-')[0].toLowerCase()
}, },
@@ -1003,6 +1049,8 @@ export default {
return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent) return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent)
}, },
defaultDateFormat () { defaultDateFormat () {
if (this.dateFormats.length) return this.dateFormats[0]
const isUsBrowser = Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') const isUsBrowser = Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US')
const isUsTimezone = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/) const isUsTimezone = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)
@@ -1044,6 +1092,43 @@ export default {
return map return map
}, },
fieldsUuidIndex () {
return this.template.fields.reduce((acc, f) => {
acc[f.uuid] = f
return acc
}, {})
},
conditionalFieldIndex () {
if (!this.inputMode) return {}
const cache = {}
return this.template.fields.reduce((acc, f) => {
acc[f.uuid] = this.checkFieldConditions(f, cache)
return acc
}, {})
},
formulaValuesIndex () {
const formulaFields = this.template.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.hasFormulaDependencyValue(f))
if (!formulaFields.length) return {}
if (!this.isMathLoaded) {
this.loadCalculator()
return {}
}
return formulaFields.reduce((acc, f) => {
if (this.conditionalFieldIndex[f.uuid] !== false) {
acc[f.uuid] = this.calculateFormula(f)
}
return acc
}, {})
},
isAllRequiredFieldsAdded () { isAllRequiredFieldsAdded () {
return !this.defaultRequiredFields?.some((f) => { return !this.defaultRequiredFields?.some((f) => {
return !this.template.fields?.some((field) => field.name === f.name) return !this.template.fields?.some((field) => field.name === f.name)
@@ -1052,6 +1137,39 @@ export default {
selectedField () { selectedField () {
return this.template.fields.find((f) => f.areas?.includes(this.lastSelectedArea)) return this.template.fields.find((f) => f.areas?.includes(this.lastSelectedArea))
}, },
detectFieldsIndex () {
const submittersByUuid = {}
this.template.submitters.forEach((s) => {
submittersByUuid[s.uuid] = s
})
const index = {}
this.template.fields.forEach((f) => {
if (!f.name) return
const role = submittersByUuid[f.submitter_uuid]?.name
const key = [f.name, role].filter(Boolean).join(':').toLowerCase()
if (!index[key]) index[key] = f
})
return index
},
detectCustomFieldsIndex () {
const index = {}
;[...this.customFields, ...this.defaultRequiredFields, ...this.defaultFields].forEach((c) => {
if (!c.name) return
const key = [c.name, c.role].filter(Boolean).join(':').toLowerCase()
if (!index[key]) index[key] = c
})
return index
},
sortedDocuments () { sortedDocuments () {
return this.template.schema.map((item) => { return this.template.schema.map((item) => {
return this.template.documents.find(doc => doc.uuid === item.attachment_uuid) return this.template.documents.find(doc => doc.uuid === item.attachment_uuid)
@@ -1097,6 +1215,16 @@ export default {
} }
}) })
const deduplicateUuidsIndex = {}
this.template.submitters.forEach((submitter) => {
if (deduplicateUuidsIndex[submitter.uuid]) {
submitter.uuid = v4()
}
deduplicateUuidsIndex[submitter.uuid] = true
})
this.selectedSubmitter = this.template.submitters[0] this.selectedSubmitter = this.template.submitters[0]
}, },
mounted () { mounted () {
@@ -1137,6 +1265,132 @@ export default {
}, },
methods: { methods: {
toRaw, toRaw,
applyCustomFieldAttributes: Fields.methods.applyCustomFieldAttributes,
buildExistingFields: Fields.methods.buildExistingFields,
async loadCalculator () {
if (this.math) return
const { Calculator } = await import('../submission_form/calculator')
this.math = new Calculator()
this.isMathLoaded = true
},
optionValue (option, index) {
if (option.value) {
return option.value
} else {
return `${this.t('option')} ${index + 1}`
}
},
checkFieldConditions (field, cache = {}) {
const cacheKey = field.uuid || field.attachment_uuid
if (cache[cacheKey] !== undefined) {
return cache[cacheKey]
}
if (field.conditions?.length) {
const result = field.conditions.reduce((acc, cond) => {
if (cond.operation === 'or') {
acc.push(acc.pop() || this.checkFieldCondition(cond, cache))
} else {
acc.push(this.checkFieldCondition(cond, cache))
}
return acc
}, [])
cache[cacheKey] = !result.includes(false)
} else {
cache[cacheKey] = true
}
return cache[cacheKey]
},
checkFieldCondition (condition, cache = {}) {
const field = this.fieldsUuidIndex[condition.field_uuid]
if (['not_empty', 'checked', 'equal', 'contains', 'greater_than', 'less_than'].includes(condition.action) && field && !this.checkFieldConditions(field, cache)) {
return false
}
const defaultValue = !field || isEmpty(field.default_value) ? null : field.default_value
if (['empty', 'unchecked'].includes(condition.action)) {
return isEmpty(defaultValue)
} else if (['not_empty', 'checked'].includes(condition.action)) {
return !isEmpty(defaultValue)
} else if (field?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)) {
const value = defaultValue
if (isEmpty(value) || isEmpty(condition.value)) return false
const actual = parseFloat(value)
const expected = parseFloat(condition.value)
if (Number.isNaN(actual) || Number.isNaN(expected)) return false
if (condition.action === 'equal') return Math.abs(actual - expected) < Number.EPSILON
if (condition.action === 'not_equal') return Math.abs(actual - expected) > Number.EPSILON
if (condition.action === 'greater_than') return actual > expected
if (condition.action === 'less_than') return actual < expected
return false
} else if (['equal', 'contains'].includes(condition.action) && field) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
if (option) {
const values = [defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return [defaultValue].flat().includes(condition.value)
}
} else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
if (option) {
const values = [defaultValue].flat()
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return false
}
} else {
return true
}
},
normalizeFormula (formula, depth = 0) {
if (depth > 10) return formula
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
if (this.fieldsUuidIndex[uuid]?.preferences?.formula) {
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
} else {
return match
}
})
},
calculateFormula (field) {
const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
return this.fieldsUuidIndex[uuid]?.default_value || 0.0
})
return this.math.evaluate(transformedFormula.toLowerCase())
},
hasFormulaDependencyValue (field) {
const normalized = this.normalizeFormula(field.preferences.formula)
return [...normalized.matchAll(/{{(.*?)}}/g)].some(([, uuid]) => !isEmpty(this.fieldsUuidIndex[uuid]?.default_value))
},
addCustomField (field) { addCustomField (field) {
return this.$refs.fields.addCustomField(field) return this.$refs.fields.addCustomField(field)
}, },
@@ -1416,33 +1670,26 @@ export default {
this.save() this.save()
} }
}, },
compareAreas (a, b) {
const aAttIdx = this.attachmentUuidsIndex[a.attachment_uuid]
const bAttIdx = this.attachmentUuidsIndex[b.attachment_uuid]
if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx
if (a.page !== b.page) return a.page - b.page
const aY = a.y + a.h
const bY = b.y + b.h
if (Math.abs(aY - bY) < 0.01) return a.x - b.x
if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
return aY - bY
},
findFieldInsertIndex (field) { findFieldInsertIndex (field) {
if (!field.areas?.length) return -1 if (!field.areas?.length) return -1
const area = field.areas[0] const area = field.areas[0]
const attachmentUuidsIndex = this.template.schema.reduce((acc, e, index) => {
acc[e.attachment_uuid] = index
return acc
}, {})
const compareAreas = (a, b) => {
const aAttIdx = attachmentUuidsIndex[a.attachment_uuid]
const bAttIdx = attachmentUuidsIndex[b.attachment_uuid]
if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx
if (a.page !== b.page) return a.page - b.page
const aY = a.y + a.h
const bY = b.y + b.h
if (Math.abs(aY - bY) < 0.01) return a.x - b.x
if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
return aY - bY
}
let closestBeforeIndex = -1 let closestBeforeIndex = -1
let closestBeforeArea = null let closestBeforeArea = null
let closestAfterIndex = -1 let closestAfterIndex = -1
@@ -1451,15 +1698,15 @@ export default {
this.template.fields.forEach((f, index) => { this.template.fields.forEach((f, index) => {
if (f.submitter_uuid === field.submitter_uuid) { if (f.submitter_uuid === field.submitter_uuid) {
(f.areas || []).forEach((a) => { (f.areas || []).forEach((a) => {
const cmp = compareAreas(a, area) const cmp = this.compareAreas(a, area)
if (cmp < 0) { if (cmp < 0) {
if (!closestBeforeArea || (compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) { if (!closestBeforeArea || (this.compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) {
closestBeforeIndex = index closestBeforeIndex = index
closestBeforeArea = a closestBeforeArea = a
} }
} else { } else {
if (!closestAfterArea || (compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) { if (!closestAfterArea || (this.compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) {
closestAfterIndex = index closestAfterIndex = index
closestAfterArea = a closestAfterArea = a
} }
@@ -1482,6 +1729,41 @@ export default {
this.template.fields.push(field) this.template.fields.push(field)
} }
}, },
insertArea (field, area) {
field.areas ||= []
const insertIndex = field.areas.findIndex((a) => this.compareAreas(a, area) > 0)
if (insertIndex === -1) {
field.areas.push(area)
} else {
field.areas.splice(insertIndex, 0, area)
}
},
insertDetectedField (field) {
if (!this.withDetectExistingFields || !field.name) {
this.insertField(field)
return
}
const role = this.template.submitters.find((s) => s.uuid === field.submitter_uuid)?.name
const nameKey = field.name.toLowerCase()
const indexKey = [field.name, role].filter(Boolean).join(':').toLowerCase()
const existingField = this.detectFieldsIndex[indexKey]
if (existingField) {
existingField.areas = existingField.areas || []
field.areas.forEach((area) => this.insertArea(existingField, area))
} else {
const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey]
if (customField) this.applyCustomFieldAttributes(field, customField)
this.insertField(field)
}
},
closeDropdown () { closeDropdown () {
document.activeElement.blur() document.activeElement.blur()
}, },
@@ -1978,7 +2260,7 @@ export default {
fieldUuidIndex[field.uuid] = newField fieldUuidIndex[field.uuid] = newField
newField.areas.push(newArea) this.insertArea(newField, newArea)
newAreas.push(newArea) newAreas.push(newArea)
if (['radio', 'multiple'].includes(field.type) && field.options?.length) { if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
@@ -2091,17 +2373,7 @@ export default {
area.y -= area.h / 2 area.y -= area.h / 2
} }
this.drawField.areas ||= [] this.insertArea(this.drawField, area)
const insertBeforeAreaIndex = this.drawField.areas.findIndex((a) => {
return a.attachment_uuid === area.attachment_uuid && a.page > area.page
})
if (insertBeforeAreaIndex !== -1) {
this.drawField.areas.splice(insertBeforeAreaIndex, 0, area)
} else {
this.drawField.areas.push(area)
}
if (this.template.fields.indexOf(this.drawField) === -1) { if (this.template.fields.indexOf(this.drawField) === -1) {
this.insertField(this.drawField) this.insertField(this.drawField)
@@ -2242,9 +2514,7 @@ export default {
delete field.height delete field.height
} }
field.areas ||= [] this.insertArea(field, fieldArea)
field.areas.push(fieldArea)
if (this.selectedAreasRef.value.length < 2) { if (this.selectedAreasRef.value.length < 2) {
this.selectedAreasRef.value = [fieldArea] this.selectedAreasRef.value = [fieldArea]
@@ -2314,7 +2584,7 @@ export default {
} }
} }
field.areas.push(fieldArea) this.insertArea(field, fieldArea)
}) })
} else { } else {
const fieldArea = { const fieldArea = {
@@ -2773,6 +3043,12 @@ export default {
}) })
}, },
detectFieldsForPage ({ page, attachmentUuid }) { detectFieldsForPage ({ page, attachmentUuid }) {
if (this.isDetectingPageFields) {
this.detectFieldsQueue.push({ page, attachmentUuid })
return
}
this.isDetectingPageFields = true this.isDetectingPageFields = true
this.detectingAnalyzingProgress = null this.detectingAnalyzingProgress = null
this.detectingFieldsAddedCount = null this.detectingFieldsAddedCount = null
@@ -2811,7 +3087,11 @@ export default {
this.baseFetch(`/templates/${this.template.id}/detect_fields`, { this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attachment_uuid: attachmentUuid, page }) body: JSON.stringify({
attachment_uuid: attachmentUuid,
page,
...(this.withDetectExistingFields ? { fields: this.buildExistingFields() } : {})
})
}).then(async (response) => { }).then(async (response) => {
const reader = response.body.getReader() const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8') const decoder = new TextDecoder('utf-8')
@@ -2840,7 +3120,7 @@ export default {
if (!f.submitter_uuid) { if (!f.submitter_uuid) {
f.submitter_uuid = this.template.submitters[0].uuid f.submitter_uuid = this.template.submitters[0].uuid
} }
this.insertField(f) this.insertDetectedField(f)
}) })
totalFieldsAdded += errorFields.length totalFieldsAdded += errorFields.length
@@ -2869,7 +3149,7 @@ export default {
const nonOverlappingFields = filterNonOverlappingFields(finalFields) const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f)) nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
totalFieldsAdded += nonOverlappingFields.length totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) { if (nonOverlappingFields.length) {
@@ -2907,7 +3187,7 @@ export default {
const nonOverlappingFields = filterNonOverlappingFields(finalFields) const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f)) nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
totalFieldsAdded += nonOverlappingFields.length totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) { if (nonOverlappingFields.length) {
@@ -2925,7 +3205,7 @@ export default {
const nonOverlappingFields = filterNonOverlappingFields(finalFields) const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f)) nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
totalFieldsAdded += nonOverlappingFields.length totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) { if (nonOverlappingFields.length) {
@@ -2958,6 +3238,10 @@ export default {
setTimeout(() => { setTimeout(() => {
this.detectingFieldsAddedCount = null this.detectingFieldsAddedCount = null
}, 1000) }, 1000)
if (this.detectFieldsQueue.length) {
this.detectFieldsForPage(this.detectFieldsQueue.shift())
}
}) })
}, },
save ({ force } = { force: false }) { save ({ force } = { force: false }) {
+18 -1
View File
@@ -5,6 +5,8 @@
:key="image.id" :key="image.id"
:ref="setPageRefs" :ref="setPageRefs"
:input-mode="inputMode" :input-mode="inputMode"
:conditional-field-index="conditionalFieldIndex"
:formula-values-index="formulaValuesIndex"
:number="index" :number="index"
:editable="editable" :editable="editable"
:data-page="index" :data-page="index"
@@ -64,6 +66,16 @@ export default {
required: false, required: false,
default: false default: false
}, },
conditionalFieldIndex: {
type: Object,
required: false,
default: () => ({})
},
formulaValuesIndex: {
type: Object,
required: false,
default: () => ({})
},
areasIndex: { areasIndex: {
type: Object, type: Object,
required: false, required: false,
@@ -138,6 +150,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
pagePreviewFormat: {
type: String,
required: false,
default: '.jpg'
},
withFieldsDetection: { withFieldsDetection: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -168,7 +185,7 @@ export default {
return this.previewImagesIndex[i] || reactive({ return this.previewImagesIndex[i] || reactive({
metadata: { ...lazyloadMetadata }, metadata: { ...lazyloadMetadata },
id: Math.random().toString(), id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}.jpg` url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}${this.pagePreviewFormat}`
}) })
}) })
}, },
@@ -25,6 +25,13 @@ tiptapStylesheet.replaceSync(
-webkit-font-variant-ligatures: none; -webkit-font-variant-ligatures: none;
font-variant-ligatures: none; font-variant-ligatures: none;
font-feature-settings: "liga" 0; font-feature-settings: "liga" 0;
display: flex;
flex-flow: column nowrap;
min-height: inherit;
}
.ProseMirror > article {
margin-bottom: auto;
} }
.ProseMirror [contenteditable="false"] { .ProseMirror [contenteditable="false"] {
@@ -188,6 +195,8 @@ const CustomHeading = Node.create({
const SectionNode = createBlockNode('section', 'section') const SectionNode = createBlockNode('section', 'section')
const ArticleNode = createBlockNode('article', 'article') const ArticleNode = createBlockNode('article', 'article')
const HeaderNode = createBlockNode('header', 'header')
const FooterNode = createBlockNode('footer', 'footer')
const DivNode = createBlockNode('div', 'div') const DivNode = createBlockNode('div', 'div')
const BlockquoteNode = createBlockNode('blockquote', 'blockquote') const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
const PreNode = createBlockNode('pre', 'pre') const PreNode = createBlockNode('pre', 'pre')
@@ -738,14 +747,12 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor
History, History,
Gapcursor, Gapcursor,
Dropcursor, Dropcursor,
CustomBold,
CustomItalic,
CustomUnderline,
CustomStrike,
CustomParagraph, CustomParagraph,
CustomHeading, CustomHeading,
SectionNode, SectionNode,
ArticleNode, ArticleNode,
HeaderNode,
FooterNode,
DivNode, DivNode,
BlockquoteNode, BlockquoteNode,
PreNode, PreNode,
@@ -764,6 +771,10 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor
EmptySpanNode, EmptySpanNode,
LinkMark, LinkMark,
SpanMark, SpanMark,
CustomBold,
CustomItalic,
CustomUnderline,
CustomStrike,
SubscriptMark, SubscriptMark,
SuperscriptMark, SuperscriptMark,
VariableHighlight, VariableHighlight,
@@ -119,7 +119,7 @@
@change="[schema.format = $event.target.value, save()]" @change="[schema.format = $event.target.value, save()]"
> >
<option <option
v-for="format in dateFormats" v-for="format in availableDateFormats"
:key="format" :key="format"
:value="format" :value="format"
>{{ formatDate(new Date(), format) }}</option> >{{ formatDate(new Date(), format) }}</option>
@@ -248,7 +248,7 @@ export default {
FieldType, FieldType,
IconSettings IconSettings
}, },
inject: ['t', 'save', 'backgroundColor'], inject: ['t', 'save', 'backgroundColor', 'dateFormats'],
provide () { provide () {
return { return {
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select'] fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
@@ -318,20 +318,23 @@ export default {
'space' 'space'
] ]
}, },
dateFormats () { availableDateFormats () {
const formats = [ const formats = this.dateFormats.length
'MM/DD/YYYY', ? [...this.dateFormats]
'DD/MM/YYYY', : [
'YYYY-MM-DD', 'MM/DD/YYYY',
'DD-MM-YYYY', 'DD/MM/YYYY',
'DD.MM.YYYY', 'YYYY-MM-DD',
'MMM D, YYYY', 'DD-MM-YYYY',
'MMMM D, YYYY', 'DD.MM.YYYY',
'D MMM YYYY', 'MMM D, YYYY',
'D MMMM YYYY' 'MMMM D, YYYY',
] 'MMMM YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) { if (!this.dateFormats.length && (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko'))) {
formats.push('YYYY년 MM월 DD일') formats.push('YYYY년 MM월 DD일')
} }
@@ -401,18 +404,47 @@ export default {
formatDate (date, format) { formatDate (date, format) {
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' } const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' } const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YY: '2-digit' } const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
const minuteFormats = { m: 'numeric', mm: '2-digit' }
const secondFormats = { s: 'numeric', ss: '2-digit' }
const parts = new Intl.DateTimeFormat([], { const opts = {
day: dayFormats[format.match(/D+/)], day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)], month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)] year: yearFormats[format.match(/Y+/)]
}).formatToParts(date) }
return format if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
.replace(/D+/, parts.find((p) => p.type === 'day').value) if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
.replace(/M+/, parts.find((p) => p.type === 'month').value) if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
.replace(/Y+/, parts.find((p) => p.type === 'year').value) if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
const partTypes = {
M: 'month',
D: 'day',
Y: 'year',
H: 'hour',
h: 'hour',
m: 'minute',
s: 'second',
z: 'timeZoneName',
A: 'dayPeriod',
a: 'dayPeriod'
}
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
if (token === 'A') return (value || '').toUpperCase()
if (token === 'a') return (value || '').toLowerCase()
return value
})
}, },
closeDropdown () { closeDropdown () {
this.$el.getRootNode().activeElement.blur() this.$el.getRootNode().activeElement.blur()
+3 -2
View File
@@ -345,7 +345,7 @@ export default {
IconMathFunction, IconMathFunction,
FieldType FieldType
}, },
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex'], inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -428,7 +428,8 @@ export default {
if (this.field.type === 'date') { if (this.field.type === 'date') {
this.field.preferences.format ||= this.field.preferences.format ||=
({ 'de-DE': 'DD.MM.YYYY' }[this.locale] || ((Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') || new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)) ? 'MM/DD/YYYY' : 'DD/MM/YYYY')) this.dateFormats[0] ||
({ 'de-DE': 'DD.MM.YYYY' }[this.locale] || ((Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') || new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)) ? 'MM/DD/YYYY' : 'DD/MM/YYYY'))
} }
}, },
methods: { methods: {
@@ -514,7 +514,7 @@ export default {
ContextSubmenu, ContextSubmenu,
ContextModal ContextModal
}, },
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies'], inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats'],
props: { props: {
contextMenu: { contextMenu: {
type: Object, type: Object,
@@ -580,7 +580,7 @@ export default {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels, fieldLabels: FieldType.computed.fieldLabels,
validationOptions: FieldSettings.computed.validations, validationOptions: FieldSettings.computed.validations,
dateFormats: FieldSettings.computed.dateFormats, availableDateFormats: FieldSettings.computed.availableDateFormats,
numberFormats: FieldSettings.computed.numberFormats, numberFormats: FieldSettings.computed.numberFormats,
prefillableFieldTypes: FieldSettings.computed.prefillableFieldTypes, prefillableFieldTypes: FieldSettings.computed.prefillableFieldTypes,
verificationMethods: FieldSettings.computed.verificationMethods, verificationMethods: FieldSettings.computed.verificationMethods,
@@ -686,7 +686,7 @@ export default {
}, },
formatOptions () { formatOptions () {
switch (this.field.type) { switch (this.field.type) {
case 'date': return this.dateFormats.map(f => ({ value: f, label: this.formatDate(new Date(), f) })) case 'date': return this.availableDateFormats.map(f => ({ value: f, label: this.formatDate(new Date(), f) }))
case 'number': return this.numberFormats.map(f => ({ value: f, label: this.formatNumber(123456789.567, f) })) case 'number': return this.numberFormats.map(f => ({ value: f, label: this.formatNumber(123456789.567, f) }))
case 'signature': return this.signatureFormats.map(f => ({ value: f, label: this.t(f) })) case 'signature': return this.signatureFormats.map(f => ({ value: f, label: this.t(f) }))
default: return [] default: return []
@@ -290,7 +290,7 @@
@change="$emit('save')" @change="$emit('save')"
> >
<option <option
v-for="format in dateFormats" v-for="format in availableDateFormats"
:key="format" :key="format"
:value="format" :value="format"
> >
@@ -610,7 +610,7 @@ export default {
IconTypography, IconTypography,
IconX IconX
}, },
inject: ['template', 't'], inject: ['template', 't', 'dateFormats'],
props: { props: {
field: { field: {
type: Object, type: Object,
@@ -701,20 +701,23 @@ export default {
'space' 'space'
] ]
}, },
dateFormats () { availableDateFormats () {
const formats = [ const formats = this.dateFormats.length
'MM/DD/YYYY', ? [...this.dateFormats]
'DD/MM/YYYY', : [
'YYYY-MM-DD', 'MM/DD/YYYY',
'DD-MM-YYYY', 'DD/MM/YYYY',
'DD.MM.YYYY', 'YYYY-MM-DD',
'MMM D, YYYY', 'DD-MM-YYYY',
'MMMM D, YYYY', 'DD.MM.YYYY',
'D MMM YYYY', 'MMM D, YYYY',
'D MMMM YYYY' 'MMMM D, YYYY',
] 'MMMM YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) { if (!this.dateFormats.length && (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko'))) {
formats.push('YYYY년 MM월 DD일') formats.push('YYYY년 MM월 DD일')
} }
@@ -783,17 +786,27 @@ export default {
}, },
copyToAllPages (field) { copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0]) const areaString = JSON.stringify(field.areas[0])
const newAreas = []
const existingAreasIndex = field.areas.reduce((acc, area) => {
acc[`${area.attachment_uuid}-${area.page}`] = area
return acc
}, {})
this.template.schema.forEach((item) => {
const attachment = this.template.documents.find((d) => d.uuid === item.attachment_uuid)
this.template.documents.forEach((attachment) => {
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
for (let page = 0; page <= numberOfPages - 1; page++) { for (let page = 0; page <= numberOfPages - 1; page++) {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) { const existing = existingAreasIndex[`${attachment.uuid}-${page}`]
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
} newAreas.push(existing || { ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
} }
}) })
field.areas = newAreas
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1]) this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
this.$emit('save') this.$emit('save')
@@ -819,33 +832,49 @@ export default {
return pattern?.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups || null return pattern?.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups || null
}, },
formatDate (date, format) { formatDate (date, format) {
const monthFormats = { const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
M: 'numeric', const dayFormats = { D: 'numeric', DD: '2-digit' }
MM: '2-digit', const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
MMM: 'short', const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
MMMM: 'long' const minuteFormats = { m: 'numeric', mm: '2-digit' }
} const secondFormats = { s: 'numeric', ss: '2-digit' }
const dayFormats = { const opts = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
day: dayFormats[format.match(/D+/)], day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)], month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)] year: yearFormats[format.match(/Y+/)]
}).formatToParts(date) }
return format if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
.replace(/D+/, parts.find((p) => p.type === 'day').value) if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
.replace(/M+/, parts.find((p) => p.type === 'month').value) if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
.replace(/Y+/, parts.find((p) => p.type === 'year').value) if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
const partTypes = {
M: 'month',
D: 'day',
Y: 'year',
H: 'hour',
h: 'hour',
m: 'minute',
s: 'second',
z: 'timeZoneName',
A: 'dayPeriod',
a: 'dayPeriod'
}
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
if (token === 'A') return (value || '').toUpperCase()
if (token === 'a') return (value || '').toLowerCase()
return value
})
} }
} }
} }
+119 -8
View File
@@ -65,13 +65,33 @@
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
/> />
</div> </div>
<div v-if="!isShowVariables && submitterDefaultFields.length && editable"> <div
<hr class="mb-2"> v-if="editable && withCustomFieldsTab"
class="tabs w-full mb-1.5 custom-fields-tabs"
>
<a
class="tab tab-bordered w-1/2 border-base-300 custom-fields-tab"
:class="{ 'tab-active': !showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0px' : '0.5px' }"
@click="setFieldsTab('default')"
>{{ t('default') }}</a>
<a
class="tab tab-bordered w-1/2 border-base-300 custom-fields-tab"
:class="{ 'tab-active': showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0.5px' : '0px' }"
@click="setFieldsTab('custom')"
>{{ t('custom') }}</a>
</div>
<div v-if="!isShowVariables && submitterDefaultFields.length && editable && (!withCustomFieldsTab || showCustomTab)">
<hr
v-if="!withCustomFieldsTab"
class="mb-1.5"
>
<template v-if="isShowFieldSearch"> <template v-if="isShowFieldSearch">
<input <input
v-model="defaultFieldsSearch" v-model="defaultFieldsSearch"
:placeholder="t('search_field')" :placeholder="t('search_field')"
class="input input-ghost input-xs px-0 text-base mb-2 !outline-0 !rounded bg-transparent w-full" class="input input-ghost input-xs px-0 text-base mb-1.5 !outline-0 !rounded bg-transparent w-full"
> >
<hr class="mb-2"> <hr class="mb-2">
</template> </template>
@@ -218,7 +238,7 @@
</div> </div>
</div> </div>
<div <div
v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))" v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField)) && (!withCustomFieldsTab || !showCustomTab)"
id="field-types-grid" id="field-types-grid"
class="grid grid-cols-3 gap-1 pb-2 fields-grid" class="grid grid-cols-3 gap-1 pb-2 fields-grid"
> >
@@ -418,6 +438,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withCustomFieldsTab: {
type: Boolean,
required: false,
default: false
},
withFieldsSearch: { withFieldsSearch: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -428,6 +453,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withDetectExistingFields: {
type: Boolean,
required: false,
default: false
},
withSignatureId: { withSignatureId: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -495,6 +525,11 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
detectCustomFieldsIndex: {
type: Object,
required: false,
default: () => ({})
},
showTourStartForm: { showTourStartForm: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -557,7 +592,8 @@ export default {
}, },
submitterDefaultFields () { submitterDefaultFields () {
return this.defaultFields.filter((f) => { return this.defaultFields.filter((f) => {
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name) return (this.withCustomFieldsTab ? true : !this.submitterFields.find((field) => field.name === f.name)) &&
(!f.role || f.role === this.selectedSubmitter.name)
}) })
}, },
filteredSubmitterDefaultFields () { filteredSubmitterDefaultFields () {
@@ -656,6 +692,78 @@ export default {
this.customFields.splice(0, this.customFields.length, ...fields) this.customFields.splice(0, this.customFields.length, ...fields)
}) })
}, },
buildExistingFields () {
const existing = []
const seen = new Set()
const submittersByUuid = this.template.submitters.reduce((acc, s) => {
acc[s.uuid] = s
return acc
}, {})
const add = (field, role) => {
if (!field?.name) return
const key = field.name.toLowerCase() + ':' + (role || '').toLowerCase()
if (seen.has(key)) return
seen.add(key)
const item = { name: field.name, type: field.type || 'text' }
if (role) item.role = role
const optionValues = Array.isArray(field.options)
? field.options.map((o) => (typeof o === 'string' ? o : o?.value)).filter(Boolean)
: []
if (optionValues.length) item.options = optionValues
existing.push(item)
}
this.template.fields.forEach((f) => add(f, submittersByUuid[f.submitter_uuid]?.name))
this.defaultRequiredFields.forEach((f) => add(f, f.role))
this.defaultFields.forEach((f) => add(f, f.role))
this.customFields.forEach((f) => add(f, f.role))
return existing
},
enrichDetectedField (field) {
if (!this.withDetectExistingFields || !field.name) return field
const role = this.template.submitters.find((s) => s.uuid === field.submitter_uuid)?.name
const nameKey = field.name.toLowerCase()
const indexKey = [field.name, role].filter(Boolean).join(':').toLowerCase()
const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey]
if (customField) this.applyCustomFieldAttributes(field, customField)
return field
},
applyCustomFieldAttributes (field, customField) {
const skipKeys = new Set(['uuid', 'areas', 'submitter_uuid', 'conditions', 'prefillable', 'role'])
Object.entries(customField).forEach(([key, value]) => {
if (skipKeys.has(key)) return
if (value === null || value === undefined) return
if (key === 'options') {
if (Array.isArray(value) && !Array.isArray(field.options)) {
field.options = value.map((o) => (
typeof o === 'string' ? { value: o, uuid: v4() } : { ...o, uuid: v4() }
))
}
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
field[key] = JSON.parse(JSON.stringify(value))
} else {
field[key] = value
}
})
},
detectFields () { detectFields () {
const fields = [] const fields = []
@@ -665,7 +773,10 @@ export default {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} },
...(this.withDetectExistingFields
? { body: JSON.stringify({ fields: this.buildExistingFields() }) }
: {})
}).then(async (response) => { }).then(async (response) => {
const reader = response.body.getReader() const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8') const decoder = new TextDecoder('utf-8')
@@ -687,7 +798,7 @@ export default {
if (data.error) { if (data.error) {
if ((data.fields || fields).length) { if ((data.fields || fields).length) {
this.template.fields = data.fields || fields this.template.fields = (data.fields || fields).map((f) => this.enrichDetectedField(f))
this.save() this.save()
} else { } else {
@@ -705,7 +816,7 @@ export default {
this.$emit('select-submitter', this.template.submitters[0]) this.$emit('select-submitter', this.template.submitters[0])
} }
this.template.fields = data.fields || fields this.template.fields = (data.fields || fields).map((f) => this.enrichDetectedField(f))
this.save() this.save()
+7
View File
@@ -70,6 +70,7 @@ const en = {
sign_yourself: 'Sign Yourself', sign_yourself: 'Sign Yourself',
set_signing_date: 'Set signing date', set_signing_date: 'Set signing date',
signing_date: 'Signing Date', signing_date: 'Signing Date',
signing_date_and_time: 'Signing Date and Time',
send: 'Send', send: 'Send',
remove: 'Remove', remove: 'Remove',
edit: 'Edit', edit: 'Edit',
@@ -271,6 +272,7 @@ const es = {
with_logo: 'Con logotipo', with_logo: 'Con logotipo',
description: 'Descripción', description: 'Descripción',
signing_date: 'Fecha de Firma', signing_date: 'Fecha de Firma',
signing_date_and_time: 'Fecha y Hora de Firma',
display_title: 'Título de visualización', display_title: 'Título de visualización',
unchecked: 'No marcado', unchecked: 'No marcado',
price: 'Precio', price: 'Precio',
@@ -508,6 +510,7 @@ const it = {
sign_yourself: 'Firma te stesso', sign_yourself: 'Firma te stesso',
set_signing_date: 'Imposta data di firma', set_signing_date: 'Imposta data di firma',
signing_date: 'Data di firma', signing_date: 'Data di firma',
signing_date_and_time: 'Data e ora di firma',
send: 'Invia', send: 'Invia',
remove: 'Rimuovi', remove: 'Rimuovi',
edit: 'Modifica', edit: 'Modifica',
@@ -710,6 +713,7 @@ const pt = {
description: 'Descrição', description: 'Descrição',
display_title: 'Título de exibição', display_title: 'Título de exibição',
signing_date: 'Data da Assinatura', signing_date: 'Data da Assinatura',
signing_date_and_time: 'Data e Hora da Assinatura',
unchecked: 'Não marcado', unchecked: 'Não marcado',
price: 'Preço', price: 'Preço',
equal: 'Igual', equal: 'Igual',
@@ -946,6 +950,7 @@ const fr = {
sign_yourself: 'Signer vous-même', sign_yourself: 'Signer vous-même',
set_signing_date: 'Définir la date de signature', set_signing_date: 'Définir la date de signature',
signing_date: 'Date de signature', signing_date: 'Date de signature',
signing_date_and_time: 'Date et heure de signature',
send: 'Envoyer', send: 'Envoyer',
remove: 'Supprimer', remove: 'Supprimer',
edit: 'Modifier', edit: 'Modifier',
@@ -1165,6 +1170,7 @@ const de = {
sign_yourself: 'Selbst unterschreiben', sign_yourself: 'Selbst unterschreiben',
set_signing_date: 'Unterzeichnungsdatum festlegen', set_signing_date: 'Unterzeichnungsdatum festlegen',
signing_date: 'Unterzeichnungsdatum', signing_date: 'Unterzeichnungsdatum',
signing_date_and_time: 'Unterzeichnungsdatum und -uhrzeit',
send: 'Senden', send: 'Senden',
remove: 'Entfernen', remove: 'Entfernen',
edit: 'Bearbeiten', edit: 'Bearbeiten',
@@ -1384,6 +1390,7 @@ const nl = {
sign_yourself: 'Zelf ondertekenen', sign_yourself: 'Zelf ondertekenen',
set_signing_date: 'Ondertekeningsdatum instellen', set_signing_date: 'Ondertekeningsdatum instellen',
signing_date: 'Ondertekeningsdatum', signing_date: 'Ondertekeningsdatum',
signing_date_and_time: 'Ondertekeningsdatum en -tijd',
send: 'Verzenden', send: 'Verzenden',
remove: 'Verwijderen', remove: 'Verwijderen',
edit: 'Bewerken', edit: 'Bewerken',
+12
View File
@@ -37,6 +37,8 @@
:ref="setAreaRefs" :ref="setAreaRefs"
:area="item.area" :area="item.area"
:input-mode="inputMode" :input-mode="inputMode"
:conditional-field-index="conditionalFieldIndex"
:formula-values-index="formulaValuesIndex"
:page-width="width" :page-width="width"
:page-height="height" :page-height="height"
:field="item.field" :field="item.field"
@@ -180,6 +182,16 @@ export default {
required: false, required: false,
default: false default: false
}, },
conditionalFieldIndex: {
type: Object,
required: false,
default: () => ({})
},
formulaValuesIndex: {
type: Object,
required: false,
default: () => ({})
},
defaultFields: { defaultFields: {
type: Array, type: Array,
required: false, required: false,
@@ -143,8 +143,8 @@
> >
<input <input
type="hidden" type="hidden"
name="state" name="oauth_data"
:value="oauthState" :value="oauthData"
autocomplete="off" autocomplete="off"
> >
<input <input
@@ -334,7 +334,7 @@ export default {
authenticityToken () { authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content return document.querySelector('meta[name="csrf-token"]')?.content
}, },
oauthState () { oauthData () {
const params = new URLSearchParams('') const params = new URLSearchParams('')
params.set('redir', document.location.href) params.set('redir', document.location.href)
+1 -1
View File
@@ -228,7 +228,7 @@ export default {
'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.file' 'https://www.googleapis.com/auth/drive.file'
].join(' '), ].join(' '),
state: new URLSearchParams({ oauth_data: new URLSearchParams({
redir: `/templates/${this.templateId}/edit?google_drive_open=1` redir: `/templates/${this.templateId}/edit?google_drive_open=1`
}).toString() }).toString()
} }
+1
View File
@@ -32,6 +32,7 @@ class Account < ApplicationRecord
has_many :submitters, dependent: :destroy has_many :submitters, dependent: :destroy
has_many :account_linked_accounts, dependent: :destroy has_many :account_linked_accounts, dependent: :destroy
has_many :email_events, dependent: :destroy has_many :email_events, dependent: :destroy
has_many :document_metadata, class_name: 'DocumentMetadata', dependent: :destroy
has_many :webhook_urls, dependent: :destroy has_many :webhook_urls, dependent: :destroy
has_many :webhook_events, dependent: nil has_many :webhook_events, dependent: nil
has_many :account_accesses, dependent: :destroy has_many :account_accesses, dependent: :destroy
+1
View File
@@ -43,6 +43,7 @@ class AccountConfig < ApplicationRecord
DOWNLOAD_LINKS_EXPIRE_KEY = 'download_links_expire' DOWNLOAD_LINKS_EXPIRE_KEY = 'download_links_expire'
FORCE_SSO_AUTH_KEY = 'force_sso_auth' FORCE_SSO_AUTH_KEY = 'force_sso_auth'
FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf' FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf'
ROTATE_INCREMENTAL_PDF_KEY = 'rotate_incremental_pdf'
WITH_SIGNATURE_ID = 'with_signature_id' WITH_SIGNATURE_ID = 'with_signature_id'
WITH_FILE_LINKS_KEY = 'with_file_links' WITH_FILE_LINKS_KEY = 'with_file_links'
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason' WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
+27
View File
@@ -0,0 +1,27 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: document_metadata
#
# id :bigint not null, primary key
# blob_checksum :string not null
# text_runs :text not null
# created_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_document_metadata_on_account_id_and_blob_checksum (account_id,blob_checksum) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class DocumentMetadata < ApplicationRecord
belongs_to :account
attribute :text_runs, :string, default: -> { {} }
serialize :text_runs, coder: JSON
end
+6 -1
View File
@@ -101,13 +101,18 @@ class Submission < ApplicationRecord
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id]) where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:declined_at].not_eq(nil))).select(1).arel.exists) .and(Submitter.arel_table[:declined_at].not_eq(nil))).select(1).arel.exists)
} }
scope :expired, -> { pending.where(expire_at: ..Time.current) } scope :expired, lambda {
where(expire_at: ..Time.current)
.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
}
enum :source, { enum :source, {
invite: 'invite', invite: 'invite',
bulk: 'bulk', bulk: 'bulk',
api: 'api', api: 'api',
embed: 'embed', embed: 'embed',
mcp: 'mcp',
link: 'link' link: 'link'
}, scope: false, prefix: true }, scope: false, prefix: true
Whitespace-only changes.
+1 -18
View File
@@ -18,24 +18,7 @@
<%= f.button button_title(title: t(:sign_in), disabled_with: t(:signing_in)), class: 'base-button' %> <%= f.button button_title(title: t(:sign_in), disabled_with: t(:signing_in)), class: 'base-button' %>
</div> </div>
<% end %> <% end %>
<% if devise_mapping.omniauthable? %> <%= render 'omniauthable' %>
<div class="space-y-4">
<% if User.omniauth_providers.include?(:google_oauth2) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: t('sign_in_with_google'), icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<% end %>
<% if User.omniauth_providers.include?(:microsoft_office365) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: t('sign_in_with_microsoft'), icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %>
<% end %>
</div>
<% end %>
<%= render 'extra_links' %> <%= render 'extra_links' %>
<%= render 'devise/shared/links' %> <%= render 'devise/shared/links' %>
</div> </div>
+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" 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> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"></path> <path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"></path>
<path d="M9 12l2 2l4 -4"></path> <path d="M9 12l2 2l4 -4"></path>

Before

Width:  |  Height:  |  Size: 389 B

After

Width:  |  Height:  |  Size: 408 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" 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 stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" /> <path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />
<path d="M7 11l5 5l5 -5" /> <path d="M7 11l5 5l5 -5" />

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 416 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" 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> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"></path> <path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"></path>
<path d="M12 9h.01"></path> <path d="M12 9h.01"></path>

Before

Width:  |  Height:  |  Size: 412 B

After

Width:  |  Height:  |  Size: 431 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" 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 stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 3a9 9 0 1 0 9 9" /> <path d="M12 3a9 9 0 1 0 9 9" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 337 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" 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 stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 18h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v7.5" /> <path d="M12 18h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v7.5" />
<path d="M3 6l9 6l9 -6" /> <path d="M3 6l9 6l9 -6" />

Before

Width:  |  Height:  |  Size: 448 B

After

Width:  |  Height:  |  Size: 467 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="<%= local_assigns[:class] %>">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M17 17h2a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2h-14a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h2" />
<path d="M17 9v-4a2 2 0 0 0 -2 -2h-6a2 2 0 0 0 -2 2v4" />
<path d="M7 15a2 2 0 0 1 2 -2h6a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2l0 -4" />
</svg>

After

Width:  |  Height:  |  Size: 529 B

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="<%= local_assigns[:class] %>">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path d="M7 17l0 .01" />
<path d="M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path d="M7 7l0 .01" />
<path d="M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path d="M17 7l0 .01" />
<path d="M14 14l3 0" />
<path d="M20 14l0 .01" />
<path d="M14 14l0 3" />
<path d="M14 20l3 0" />
<path d="M17 17l3 0" />
<path d="M20 17l0 3" />
</svg>

After

Width:  |  Height:  |  Size: 797 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747"></path> <path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747"></path>
<path d="M20 4v5h-5"></path> <path d="M20 4v5h-5"></path>

Before

Width:  |  Height:  |  Size: 409 B

After

Width:  |  Height:  |  Size: 428 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="<%= local_assigns[:class] %>"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h3" /><path d="M16 22l5 -5" /><path d="M21 21.5v-4.5h-4.5" /></svg> <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="<%= local_assigns[:class] %>"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h3" /><path d="M16 22l5 -5" /><path d="M21 21.5v-4.5h-4.5" /></svg>

Before

Width:  |  Height:  |  Size: 416 B

After

Width:  |  Height:  |  Size: 435 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <svg aria-hidden="true" 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> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 19c3.333 -2 5 -4 5 -6c0 -3 -1 -3 -2 -3s-2.032 1.085 -2 3c.034 2.048 1.658 2.877 2.5 4c1.5 2 2.5 2.5 3.5 1c.667 -1 1.167 -1.833 1.5 -2.5c1 2.333 2.333 3.5 4 3.5h2.5"></path> <path d="M3 19c3.333 -2 5 -4 5 -6c0 -3 -1 -3 -2 -3s-2.032 1.085 -2 3c.034 2.048 1.658 2.877 2.5 4c1.5 2 2.5 2.5 3.5 1c.667 -1 1.167 -1.833 1.5 -2.5c1 2.333 2.333 3.5 4 3.5h2.5"></path>
<path d="M20 17v-12c0 -1.121 -.879 -2 -2 -2s-2 .879 -2 2v12l2 2l2 -2z"></path> <path d="M20 17v-12c0 -1.121 -.879 -2 -2 -2s-2 .879 -2 2v12l2 2l2 -2z"></path>

Before

Width:  |  Height:  |  Size: 584 B

After

Width:  |  Height:  |  Size: 603 B

+1 -1
View File
@@ -1,4 +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"> <svg aria-hidden="true" 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="M18 6l-12 12"></path> <path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path> <path d="M6 6l12 12"></path>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 309 B

+3 -2
View File
@@ -20,9 +20,10 @@
</head> </head>
<body> <body>
<% if params[:modal].present? %> <% if params[:modal].present? %>
<% url_params = Rails.application.routes.recognize_path(params[:modal], method: :get) %> <% modal_uri = Addressable::URI.parse(params[:modal]) %>
<% url_params = Rails.application.routes.recognize_path(modal_uri.path, method: :get) %>
<% if url_params[:action] == 'new' %> <% if url_params[:action] == 'new' %>
<open-modal src="<%= url_for(url_params) %>"></open-modal> <open-modal src="<%= url_for(**url_params, params: modal_uri.query_values) %>"></open-modal>
<% end %> <% end %>
<% end %> <% end %>
<turbo-frame id="modal"></turbo-frame> <turbo-frame id="modal"></turbo-frame>
+1 -1
View File
@@ -1,6 +1,6 @@
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub"> <a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
<span class="flex items-center justify-between space-x-0.5 font-medium"> <span class="flex items-center justify-between space-x-0.5 font-medium">
<%= svg_icon('start', class: 'h-3 w-3') %> <%= svg_icon('start', class: 'h-3 w-3') %>
<span>11k</span> <span>12k</span>
</span> </span>
</a> </a>
+10 -6
View File
@@ -1,18 +1,22 @@
<% uuid = local_assigns[:uuid] || SecureRandom.uuid %> <% uuid = local_assigns[:uuid] || SecureRandom.uuid %>
<input type="checkbox" id="<%= uuid %>" class="modal-toggle"> <% title_id = "#{uuid}-title" %>
<div id="<%= local_assigns[:id] %>" class="modal items-start !animate-none overflow-y-auto"> <%= tag.dialog id: uuid, class: 'modal items-start overflow-y-auto', inert: true, 'aria-labelledby': (title_id if local_assigns[:title]) do %>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none"> <div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none">
<% if local_assigns[:title] %> <% if local_assigns[:title] %>
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium"> <div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span> <span id="<%= title_id %>">
<%= local_assigns[:title] %> <%= local_assigns[:title] %>
</span> </span>
<label for="<%= uuid %>" class="text-xl">&times;</label> <form method="dialog">
<button class="text-xl cursor-pointer w-6 h-6" aria-label="<%= t('close') %>">&times;</button>
</form>
</div> </div>
<% end %> <% end %>
<div> <div>
<%= yield %> <%= yield %>
</div> </div>
</div> </div>
<label class="modal-backdrop" for="<%= uuid %>"></label> <form method="dialog" class="modal-backdrop">
</div> <button aria-label="<%= t('close') %>"></button>
</form>
<% end %>
+1 -1
View File
@@ -59,7 +59,7 @@
</li> </li>
<% end %> <% end %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %> <% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full py-1' } do |f| %> <%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'w-full py-1' } do |f| %>
<label class="flex items-center pl-6 pr-4 py-2 border-y border-base-300 -ml-2 -mr-2" for="testing_toggle"> <label class="flex items-center pl-6 pr-4 py-2 border-y border-base-300 -ml-2 -mr-2" for="testing_toggle">
<submit-form data-on="change" class="flex"> <submit-form data-on="change" class="flex">
<%= f.check_box :testing_toggle, class: 'toggle', checked: current_account.testing?, style: 'height: 0.885rem; width: 1.35rem; --handleoffset: 0.395rem; margin-left: -2px; margin-right: 8px' %> <%= f.check_box :testing_toggle, class: 'toggle', checked: current_account.testing?, style: 'height: 0.885rem; width: 1.35rem; --handleoffset: 0.395rem; margin-left: -2px; margin-right: 8px' %>
+1 -1
View File
@@ -97,7 +97,7 @@
<% end %> <% end %>
<%= render 'shared/settings_nav_extra2' %> <%= render 'shared/settings_nav_extra2' %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %> <% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full' } do |f| %> <%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'w-full' } do |f| %>
<li> <li>
<label class="flex items-center text-base hover:bg-base-300 w-full justify-between" for="testing_toggle"> <label class="flex items-center text-base hover:bg-base-300 w-full justify-between" for="testing_toggle">
<span class="mr-2 w-full"> <span class="mr-2 w-full">
+1 -1
View File
@@ -1,5 +1,5 @@
<% if can?(:manage, EncryptedConfig) || (current_user != true_user && current_account.testing?) %> <% if can?(:manage, EncryptedConfig) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'flex' } do |f| %> <%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'flex' } do |f| %>
<label class="flex items-center justify-between" for="testing_toggle"> <label class="flex items-center justify-between" for="testing_toggle">
<span class="mr-2 text-lg"> <span class="mr-2 text-lg">
<%= t('test_mode') %> <%= t('test_mode') %>
+2 -2
View File
@@ -1,4 +1,4 @@
<div class="max-w-md mx-auto px-2 mt-12 mb-4"> <main 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">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -32,5 +32,5 @@
</toggle-submit> </toggle-submit>
<% end %> <% end %>
</div> </div>
</div> </main>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %> <%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %> <% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %> <% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %> <% end %>
<div class="max-w-md mx-auto px-2 mt-12 mb-4"> <main 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">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -51,4 +51,4 @@
<% end %> <% end %>
<%= button_to t(:re_send_email), start_form_email_2fa_send_index_path, params: { slug: @template.slug, resend: true, submitter: { name: params[:name] || @submitter&.name, email: params[:email] || @submitter&.email, phone: params[:phone] || @submitter&.phone } }.compact, method: :post, id: 'resend_code', class: 'hidden' %> <%= button_to t(:re_send_email), start_form_email_2fa_send_index_path, params: { slug: @template.slug, resend: true, submitter: { name: params[:name] || @submitter&.name, email: params[:email] || @submitter&.email, phone: params[:phone] || @submitter&.phone } }.compact, method: :post, id: 'resend_code', class: 'hidden' %>
</div> </div>
</div> </main>
+2 -2
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %> <% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('share_link_is_currently_disabled')) %> <% content_for(:html_description, t('share_link_is_currently_disabled')) %>
<% end %> <% end %>
<div class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4"> <main class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4">
<div class="text-center w-full space-y-6"> <div class="text-center w-full space-y-6">
<%= render 'banner' %> <%= render 'banner' %>
<p class="text-xl font-semibold text-center"> <p class="text-xl font-semibold text-center">
@@ -27,5 +27,5 @@
<%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: start_form_path(slug: @template.slug) }, method: :post, class: 'white-button w-full' %> <%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: start_form_path(slug: @template.slug) }, method: :post, class: 'white-button w-full' %>
</toggle-submit> </toggle-submit>
<% end %> <% end %>
</div> </main>
<%= render 'shared/attribution', link_path: '/start', account: @template.account %> <%= render 'shared/attribution', link_path: '/start', account: @template.account %>
+6 -4
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %> <% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %> <% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %> <% end %>
<div class="max-w-md mx-auto px-2 mt-12 mb-4"> <main 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">
<div class="text-center w-full space-y-6"> <div class="text-center w-full space-y-6">
@@ -63,6 +63,8 @@
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
</div> <div class="mt-4">
<%= render 'shared/attribution', link_path: '/start', account: @template.account %> <%= render 'shared/attribution', link_path: '/start', account: @template.account %>
<%= render 'start_form/policy', account: @template.account %> <%= render 'start_form/policy', account: @template.account %>
</div>
</main>
+14 -12
View File
@@ -5,12 +5,12 @@
<% font = field.dig('preferences', 'font') %> <% font = field.dig('preferences', 'font') %>
<% font_type = field.dig('preferences', 'font_type') %> <% font_type = field.dig('preferences', 'font_type') %>
<% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %> <% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %>
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)"> <field-value dir="auto" aria-hidden="true" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? && color.match?(Templates::COLOR_REGEXP) %><%= "background: #{bg_color}; " if bg_color.present? && bg_color.match?(Templates::COLOR_REGEXP) %>width: <%= area['w'].to_f * 100 %>%; height: <%= area['h'].to_f * 100 %>%; left: <%= area['x'].to_f * 100 %>%; top: <%= area['y'].to_f * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
<% if field['type'] == 'signature' %> <% if field['type'] == 'signature' %>
<% is_narrow = area['h'].positive? && ((area['w'] * local_assigns[:page_width]).to_f / (area['h'] * local_assigns[:page_height])) > 4.5 %> <% is_narrow = area['h'].to_f.positive? && ((area['w'].to_f * local_assigns[:page_width]) / (area['h'].to_f * local_assigns[:page_height])) > 4.5 %>
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>"> <div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
<div class="flex overflow-hidden <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%"> <div class="flex overflow-hidden <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%">
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>"> <img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" alt="<%= field['name'].presence || field['title'].presence || field['type'] %>">
</div> </div>
<% if (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) && attachment = attachments_index[value] %> <% if (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) && attachment = attachments_index[value] %>
<div class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem] <%= is_narrow ? 'w-1/2' : 'w-full' %>"> <div class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem] <%= is_narrow ? 'w-1/2' : 'w-full' %>">
@@ -35,7 +35,7 @@
<% end %> <% end %>
</div> </div>
<% elsif field['type'].in?(['image', 'initials', 'stamp', 'kba']) && attachments_index[value].image? %> <% elsif field['type'].in?(['image', 'initials', 'stamp', 'kba']) && attachments_index[value].image? %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy"> <img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'].presence || field['title'].presence || field['type'] %>">
<% elsif field['type'].in?(['file', 'payment', 'image']) %> <% elsif field['type'].in?(['file', 'payment', 'image']) %>
<autosize-field></autosize-field> <autosize-field></autosize-field>
<div class="px-0.5 flex flex-col justify-center"> <div class="px-0.5 flex flex-col justify-center">
@@ -48,6 +48,7 @@
</div> </div>
<% elsif field['type'] == 'checkbox' %> <% elsif field['type'] == 'checkbox' %>
<div class="w-full flex items-center justify-center"> <div class="w-full flex items-center justify-center">
<span class="sr-only">☑</span>
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %> <%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div> </div>
<% elsif field['type'].in?(%w[multiple radio]) && area['option_uuid'] %> <% elsif field['type'].in?(%w[multiple radio]) && area['option_uuid'] %>
@@ -55,14 +56,15 @@
<% option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}" %> <% option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}" %>
<% if Array.wrap(value).include?(option_name) %> <% if Array.wrap(value).include?(option_name) %>
<div class="w-full flex items-center justify-center"> <div class="w-full flex items-center justify-center">
<span class="sr-only">☑</span>
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %> <%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
<% elsif field['type'] == 'cells' && area['cell_w'].to_f > 0.0 %> <% elsif field['type'] == 'cells' && area['cell_w'].to_f > 0.0 %>
<% cell_width = area['cell_w'] / area['w'] * 100 %> <% cell_width = area['cell_w'].to_f / area['w'] * 100 %>
<div class="w-full flex <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %> <%= 'justify-end' if align == 'right' %>"> <div class="w-full flex <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %> <%= 'justify-end' if align == 'right' %>">
<% (0..(area['w'] / area['cell_w']).ceil).each do |index| %> <% (0..(area['w'].to_f / area['cell_w']).ceil).each do |index| %>
<% if value[index] %> <% if value[index] %>
<div class="text-center flex-none" style="width: <%= cell_width %>%;"><%= value[index] %></div> <div class="text-center flex-none" style="width: <%= cell_width %>%;"><%= value[index] %></div>
<% end %> <% end %>
@@ -71,8 +73,8 @@
<% elsif field['type'] == 'date' %> <% elsif field['type'] == 'date' %>
<autosize-field></autosize-field> <autosize-field></autosize-field>
<div class="flex w-full px-0.5 <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %>"> <div class="flex w-full px-0.5 <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %>">
<% value = Time.current.in_time_zone(local_assigns[:timezone]).to_date.to_s if value == '{{date}}' %> <% tz = local_assigns[:with_submitter_timezone] ? (submitter.timezone.presence || local_assigns[:timezone]) : local_assigns[:timezone] %>
<div class="w-full"><%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %></div> <div class="w-full"><%= value == '{{date}}' ? TimeUtils.format_date_preview(field.dig('preferences', 'format'), local_assigns[:locale], tz) : TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale], timezone: tz) %></div>
</div> </div>
<% elsif field['type'] == 'number' %> <% elsif field['type'] == 'number' %>
<autosize-field></autosize-field> <autosize-field></autosize-field>
@@ -81,14 +83,14 @@
</div> </div>
<% elsif field['type'] == 'strikethrough' %> <% elsif field['type'] == 'strikethrough' %>
<div class="w-full h-full flex items-center justify-center"> <div class="w-full h-full flex items-center justify-center">
<% if (((1000.0 / local_assigns[:page_width]) * local_assigns[:page_height]) * area['h']) < 40 %> <% if (((1000.0 / local_assigns[:page_width]) * local_assigns[:page_height]) * area['h'].to_f) < 40 %>
<svg width="100%" height="100%"> <svg width="100%" height="100%">
<line x1="0" y1="50%" x2="100%" y2="50%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line> <line x1="0" y1="50%" x2="100%" y2="50%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
</svg> </svg>
<% else %> <% else %>
<svg xmlns="http://www.w3.org/2000/svg" style="overflow: visible; width: calc(100% - 6px); height: calc(100% - 6px); width: calc(100% - 0.6cqmin); height: calc(100% - 0.6cqmin)"> <svg xmlns="http://www.w3.org/2000/svg" style="overflow: visible; width: calc(100% - 6px); height: calc(100% - 6px); width: calc(100% - 0.6cqmin); height: calc(100% - 0.6cqmin)">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line> <line x1="0" y1="0" x2="100%" y2="100%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
<line x1="100%" y1="0" x2="0" y2="100%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line> <line x1="100%" y1="0" x2="0" y2="100%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
</svg> </svg>
<% end %> <% end %>
</div> </div>
+17 -17
View File
@@ -8,11 +8,11 @@
<% with_submitter_timezone = configs.find { |e| e.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true %> <% with_submitter_timezone = configs.find { |e| e.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true %>
<% with_timestamp_seconds = configs.find { |e| e.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true %> <% with_timestamp_seconds = configs.find { |e| e.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true %>
<% with_signature_id_reason = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false %> <% with_signature_id_reason = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false %>
<div style="max-width: 1600px" class="mx-auto pl-4"> <main style="max-width: 1600px" class="mx-auto pl-4">
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100"> <div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= signed_in? && @submission.account_id == current_account&.id && @submission.template ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1"> <a href="<%= signed_in? && @submission.account_id == current_account&.id && @submission.template ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
<span><%= render 'submissions/logo' %></span> <span><%= render 'submissions/logo' %></span>
<span class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><% (@submission.name || @submission.template.name).split(/(_)/).each do |item| %><%= item %><wbr><% end %></span> <h1 class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><% (@submission.name || @submission.template.name).split(/(_)/).each do |item| %><%= item %><wbr><% end %></h1>
</a> </a>
<div class="space-x-3 flex items-center"> <div class="space-x-3 flex items-center">
<% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %> <% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %>
@@ -33,7 +33,7 @@
<% if @submission.submitters.to_a.any?(&:completed_at?) %> <% if @submission.submitters.to_a.any?(&:completed_at?) %>
<% if is_all_completed || !is_combined_enabled %> <% if is_all_completed || !is_combined_enabled %>
<div class="join relative"> <div class="join relative">
<download-button data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: is_combined_enabled }.compact_blank) : submissions_preview_download_index_path(@submission.slug, combined: is_combined_enabled.presence)) : submission_download_index_path(@submission, combined: is_combined_enabled.presence) %>" class="base-button <%= '!rounded-r-none !pr-2' if is_all_completed && !is_combined_enabled %>"> <download-button role="button" tabindex="0" aria-label="<%= t('download') %>" data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: is_combined_enabled }.compact_blank) : submissions_preview_download_index_path(@submission.slug, combined: is_combined_enabled.presence)) : submission_download_index_path(@submission, combined: is_combined_enabled.presence) %>" class="base-button <%= '!rounded-r-none !pr-2' if is_all_completed && !is_combined_enabled %>">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton"> <span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %> <%= svg_icon('download', class: 'w-6 h-6') %>
<span class="hidden md:inline"><%= t('download') %></span> <span class="hidden md:inline"><%= t('download') %></span>
@@ -45,14 +45,14 @@
</download-button> </download-button>
<% if is_all_completed && !is_combined_enabled %> <% if is_all_completed && !is_combined_enabled %>
<div class="dropdown dropdown-end"> <div class="dropdown dropdown-end">
<label tabindex="0" class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500"> <label tabindex="0" aria-label="<%= t('download') %>" class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500">
<span class="text-sm align-text-top"> <span class="text-sm align-text-top">
<%= svg_icon('chevron_down', class: 'w-6 h-6 flex-shrink-0 stroke-2') %> <%= svg_icon('chevron_down', class: 'w-6 h-6 flex-shrink-0 stroke-2') %>
</span> </span>
</label> </label>
<ul tabindex="0" class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box text-right"> <ul class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box text-right">
<li> <li>
<download-button data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: true }.compact) : submissions_preview_download_index_path(@submission.slug, combined: true)) : submission_download_index_path(@submission, combined: true) %>" class="flex items-center"> <download-button role="button" tabindex="0" data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: true }.compact) : submissions_preview_download_index_path(@submission.slug, combined: true)) : submission_download_index_path(@submission, combined: true) %>" class="flex items-center">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton"> <span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 flex-shrink-0') %> <%= svg_icon('download', class: 'w-6 h-6 flex-shrink-0') %>
<span class="whitespace-nowrap"><%= t('download_combined_pdf') %></span> <span class="whitespace-nowrap"><%= t('download_combined_pdf') %></span>
@@ -74,14 +74,14 @@
</div> </div>
</div> </div>
<div class="flex md:max-h-[calc(100vh-60px)]"> <div class="flex md:max-h-[calc(100vh-60px)]">
<div class="overflow-y-auto overflow-x-hidden hidden lg:block w-52 flex-none pr-3 mt-0.5 pt-0.5"> <div class="overflow-y-auto overflow-x-hidden hidden lg:block w-52 flex-none pr-3 mt-0.5 pt-0.5" tabindex="0">
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %> <% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% schema = Submissions.filtered_conditions_schema(@submission, values:) %> <% schema = Submissions.filtered_conditions_schema(@submission, values:) %>
<% schema.each do |item| %> <% schema.each do |item| %>
<% document = @submission.schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %> <% document = @submission.schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
<% if document.preview_images.first %> <% if document.preview_images.first %>
<scroll-to data-selector-id="page-<%= document.uuid %>-0" class="block cursor-pointer"> <scroll-to data-selector-id="page-<%= document.uuid %>-0" class="block cursor-pointer">
<img src="<%= (document.preview_images.find { |e| e.filename.base.to_i.zero? } || document.preview_images.first).url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy"> <img src="<%= (document.preview_images.find { |e| e.filename.base.to_i.zero? } || document.preview_images.first).url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy" alt="<%= item['name'].presence || document.filename.base %>">
<div class="pb-2 pt-1.5 text-center" dir="auto"> <div class="pb-2 pt-1.5 text-center" dir="auto">
<%= item['name'].presence || document.filename.base %> <%= item['name'].presence || document.filename.base %>
</div> </div>
@@ -89,7 +89,7 @@
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
<div id="document_view" class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"> <div id="document_view" class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5" tabindex="0">
<div class="pr-3.5 pl-0.5"> <div class="pr-3.5 pl-0.5">
<% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %> <% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %>
<% submitters_index = @submission.submitters.index_by(&:uuid) %> <% submitters_index = @submission.submitters.index_by(&:uuid) %>
@@ -103,9 +103,9 @@
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %> <% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.first&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %> <% lazyload_metadata = document.preview_images.first&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %> <% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %> <% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}#{Templates::ProcessDocument::PREVIEW_FORMAT}")) %>
<page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>"> <page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>"> <img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>" alt="<%= "#{item['name']} - #{t('page')} #{index + 1}" %>">
<div class="top-0 bottom-0 left-0 right-0 absolute"> <div class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %> <% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %> <%= render 'submissions/annotation', annot: %>
@@ -131,7 +131,7 @@
<% submitters_order_index ||= (@submission.template_submitters || @submission.template.submitters).each_with_index.to_h { |s, i| [s['uuid'], i] } %> <% submitters_order_index ||= (@submission.template_submitters || @submission.template.submitters).each_with_index.to_h { |s, i| [s['uuid'], i] } %>
<% submitter_index = submitters_order_index[submitter.uuid] %> <% submitter_index = submitters_order_index[submitter.uuid] %>
<% bg_class = bg_classes[submitter_index % bg_classes.size] %> <% bg_class = bg_classes[submitter_index % bg_classes.size] %>
<div class="absolute overflow-visible" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%;"> <div class="absolute overflow-visible" style="width: <%= area['w'].to_f * 100 %>%; height: <%= area['h'].to_f * 100 %>%; left: <%= area['x'].to_f * 100 %>%; top: <%= area['y'].to_f * 100 %>%;">
<div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>"> <div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>">
<%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %> <%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %>
</div> </div>
@@ -144,7 +144,7 @@
<% end %> <% end %>
</div> </div>
</div> </div>
<div id="parties_view" class="hidden md:block relative w-full md:w-80 flex-none pt-0.5 pr-4 pl-0.5 overflow-auto space"> <div id="parties_view" class="hidden md:block relative w-full md:w-80 flex-none pt-0.5 pr-4 pl-0.5 overflow-auto space" tabindex="0">
<% colors = %w[bg-red-500 bg-sky-500 bg-emerald-500 bg-yellow-300 bg-purple-600 bg-pink-500 bg-cyan-500 bg-orange-500 bg-lime-500 bg-indigo-500] %> <% colors = %w[bg-red-500 bg-sky-500 bg-emerald-500 bg-yellow-300 bg-purple-600 bg-pink-500 bg-cyan-500 bg-orange-500 bg-lime-500 bg-indigo-500] %>
<% submitter_fields_index = (@submission.template_fields || @submission.template.fields).group_by { |f| f['submitter_uuid'] } %> <% submitter_fields_index = (@submission.template_fields || @submission.template.fields).group_by { |f| f['submitter_uuid'] } %>
<% submitter_field_counters = Hash.new { 0 } %> <% submitter_field_counters = Hash.new { 0 } %>
@@ -260,10 +260,10 @@
<div dir="auto"> <div dir="auto">
<% if field['type'].in?(%w[signature initials]) %> <% if field['type'].in?(%w[signature initials]) %>
<div class="w-full bg-base-300 py-1"> <div class="w-full bg-base-300 py-1">
<img class="object-contain mx-auto" style="max-height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy"> <img class="object-contain mx-auto" style="max-height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
</div> </div>
<% elsif field['type'].in?(['image', 'stamp', 'kba']) && attachments_index[value].image? %> <% elsif field['type'].in?(['image', 'stamp', 'kba']) && attachments_index[value].image? %>
<img class="object-contain mx-auto max-h-28" style="max-height: 200px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy"> <img class="object-contain mx-auto max-h-28" style="max-height: 200px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
<% elsif field['type'].in?(['file', 'payment', 'image']) %> <% elsif field['type'].in?(['file', 'payment', 'image']) %>
<div class="flex flex-col justify-center"> <div class="flex flex-col justify-center">
<% Array.wrap(value).each do |val| %> <% Array.wrap(value).each do |val| %>
@@ -281,7 +281,7 @@
<% if field['type'] == 'number' %> <% if field['type'] == 'number' %>
<% value = NumberUtils.format_number(value, field.dig('preferences', 'format')) %> <% value = NumberUtils.format_number(value, field.dig('preferences', 'format')) %>
<% elsif field['type'] == 'date' %> <% elsif field['type'] == 'date' %>
<% value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale) %> <% value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale, timezone: (with_submitter_timezone ? (submitter.timezone.presence || @submission.account.timezone) : @submission.account.timezone)) %>
<% end %> <% end %>
<% if (mask = field.dig('preferences', 'mask').presence) %> <% if (mask = field.dig('preferences', 'mask').presence) %>
<% if signed_in? && can?(:read, @submission) %> <% if signed_in? && can?(:read, @submission) %>
@@ -313,7 +313,7 @@
</span> </span>
</label> </label>
</toggle-visible> </toggle-visible>
</div> </main>
<% unless request.headers['HTTP_X_TURBO'] %> <% unless request.headers['HTTP_X_TURBO'] %>
<%= render 'scripts/autosize_field' %> <%= render 'scripts/autosize_field' %>
<% end %> <% end %>
+2 -4
View File
@@ -1,8 +1,6 @@
<%= form_for '', url: submit_form_decline_index_path(submitter.slug), method: :post do |f| %> <%= form_for '', url: submit_form_decline_index_path(submitter.slug), method: :post do |f| %>
<div class="mt-4 text-center"> <div class="form-control mt-4">
<%= t(:notify_the_sender_with_the_reason_you_declined) %> <label class="label justify-center" for="reason"><%= t(:notify_the_sender_with_the_reason_you_declined) %></label>
</div>
<div class="form-control mt-2">
<%= f.text_area :reason, required: true, class: 'base-input w-full py-2 h-40', dir: 'auto', placeholder: t('provide_a_reason'), style: 'height: 200px', rows: '10' %> <%= f.text_area :reason, required: true, class: 'base-input w-full py-2 h-40', dir: 'auto', placeholder: t('provide_a_reason'), style: 'height: 200px', rows: '10' %>
</div> </div>
<toggle-submit dir="auto" class="form-control mt-4"> <toggle-submit dir="auto" class="form-control mt-4">
+3 -3
View File
@@ -1,4 +1,4 @@
<div class="max-w-md mx-auto px-2 mt-12 mb-4"> <main 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">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -30,7 +30,7 @@
<% end %> <% end %>
<% end %> <% end %>
<% if @submitter.completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %> <% if @submitter.completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
<download-button data-src="<%= submit_form_documents_path(@submitter.slug) %>" class="base-button w-full"> <download-button role="button" tabindex="0" aria-label="<%= t('download_documents') %>" data-src="<%= submit_form_documents_path(@submitter.slug) %>" class="base-button w-full">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton"> <span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %> <%= svg_icon('download', class: 'w-6 h-6') %>
<span><%= t('download_documents') %></span> <span><%= t('download_documents') %></span>
@@ -50,5 +50,5 @@
</toggle-submit> </toggle-submit>
<% end %> <% end %>
</div> </div>
</div> </main>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %> <%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
+4 -4
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@submitter.account.locale) do %> <% I18n.with_locale(@submitter.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @submitter.account.name)) %> <% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @submitter.account.name)) %>
<% end %> <% end %>
<div class="max-w-md mx-auto px-2 mt-12 mb-4"> <main 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">
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
@@ -46,7 +46,7 @@
<% end %> <% end %>
</span> </span>
<span> <span>
<label for="resend_code" id="resend_label" class="link"><%= t(:re_send_email) %></label> <button type="submit" form="resend_code_form" id="resend_label" class="link"><%= t(:re_send_email) %></button>
</span> </span>
</div> </div>
</div> </div>
@@ -54,7 +54,7 @@
<%= f.button button_title(title: t('submit')), class: 'base-button' %> <%= f.button button_title(title: t('submit')), class: 'base-button' %>
</toggle-submit> </toggle-submit>
<% end %> <% end %>
<%= button_to t(:re_send_email), submit_form_email_2fa_path, params: { submitter_slug: @submitter.slug, resend: true }, method: :put, id: 'resend_code', class: 'hidden' %> <%= button_to t(:re_send_email), submit_form_email_2fa_path, params: { submitter_slug: @submitter.slug, resend: true }, method: :put, form: { id: 'resend_code_form', class: 'hidden' } %>
<% else %> <% else %>
<% if params[:t] %> <% if params[:t] %>
<fetch-form data-onload="true"> <fetch-form data-onload="true">
@@ -69,4 +69,4 @@
<div><%= t('please_contact_the_requester_to_specify_your_email_for_two_factor_authentication') %></div> <div><%= t('please_contact_the_requester_to_specify_your_email_for_two_factor_authentication') %></div>
<% end %> <% end %>
</div> </div>
</div> </main>
+55 -41
View File
@@ -8,28 +8,33 @@
<% page_blob_struct = Struct.new(:url, :metadata) %> <% page_blob_struct = Struct.new(:url, :metadata) %>
<% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %> <% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %>
<% font_scale = 1000.0 / PdfUtils::US_LETTER_W %> <% font_scale = 1000.0 / PdfUtils::US_LETTER_W %>
<% decline_modal_checkbox_uuid = nil %> <% decline_modal_id = nil %>
<% delegate_modal_checkbox_uuid = nil %> <% delegate_modal_id = nil %>
<div style="max-height: -webkit-fill-available;"> <div style="max-height: -webkit-fill-available;">
<div id="scrollbox"> <main id="scrollbox">
<div id="sr_only_content"></div>
<div class="mx-auto block pb-72" style="max-width: 1000px"> <div class="mx-auto block pb-72" style="max-width: 1000px">
<%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 transition-transform duration-300 %> <%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 transition-transform duration-300 %>
<%= local_assigns[:banner_html] || capture do %> <%= local_assigns[:banner_html] || capture do %>
<%= render('submit_form/banner') %> <%= render('submit_form/banner') %>
<div id="signing_form_header" class="sticky min-[1230px]:static top-0 z-50 bg-base-100 py-2 px-2 flex items-center md:-mx-[8px]" style="margin-bottom: -16px"> <header id="signing_form_header" class="sticky min-[1230px]:static top-0 z-50 bg-base-100 py-2 px-2 flex items-center md:-mx-[8px]" style="margin-bottom: -16px">
<div class="text-xl md:text-2xl font-medium focus:text-clip" style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"> <h1 class="text-xl md:text-2xl font-medium focus:text-clip" style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<%= @submitter.submission.name || @submitter.submission.template.name %> <%= @submitter.submission.name || @submitter.submission.template.name %>
</div> </h1>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0"> <div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_delegate] %> <% if @form_configs[:with_delegate] %>
<label id="delegate_button" for="<%= delegate_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm !px-5"><%= t(:delegate) %></label> <modal-button data-target="<%= delegate_modal_id = SecureRandom.uuid %>">
<button id="delegate_button" type="button" class="btn btn-sm !px-5"><%= t(:delegate) %></button>
</modal-button>
<% if @form_configs[:with_decline] %> <% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm px-2" title="<%= t(:decline) %>"> <modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>">
<%= svg_icon('x', class: 'w-5 h-5') %> <button id="decline_button" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
</label> <%= svg_icon('x', class: 'w-5 h-5') %>
</button>
</modal-button>
<% end %> <% end %>
<% if @form_configs[:with_partial_download] %> <% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" title="<%= t('download') %>"> <download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" title="<%= t('download') %>" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton"> <span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %> <%= svg_icon('download', class: 'w-5 h-5') %>
</span> </span>
@@ -40,10 +45,12 @@
<% end %> <% end %>
<% else %> <% else %>
<% if @form_configs[:with_decline] %> <% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm !px-5"><%= t(:decline) %></label> <modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>">
<button id="decline_button" type="button" class="btn btn-sm !px-5"><%= t(:decline) %></button>
</modal-button>
<% end %> <% end %>
<% if @form_configs[:with_partial_download] %> <% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4"> <download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4" aria-label="<%= t('download') %>">
<span class="flex items-center justify-center" data-target="download-button.defaultButton"> <span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %> <%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span> <span class="hidden md:inline"><%= t('download') %></span>
@@ -56,23 +63,27 @@
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
</div> </header>
<scroll-buttons class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10"> <scroll-buttons inert class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
<% if @form_configs[:with_delegate] %> <% if @form_configs[:with_delegate] %>
<label id="delegate_button" for="<%= delegate_modal_checkbox_uuid %>" class="btn btn-sm px-0"> <modal-button data-target="<%= delegate_modal_id %>">
<span class="min-[1366px]:inline hidden px-3"> <button id="delegate_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:delegate) %>">
<%= t(:delegate) %> <span class="min-[1366px]:inline hidden px-3">
</span> <%= t(:delegate) %>
<span class="inline min-[1366px]:hidden px-2"> </span>
<%= svg_icon('user_share', class: 'w-5 h-5') %> <span class="inline min-[1366px]:hidden px-2">
</span> <%= svg_icon('user_share', class: 'w-5 h-5') %>
</label> </span>
</button>
</modal-button>
<% if @form_configs[:with_decline] %> <% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-2"> <modal-button data-target="<%= decline_modal_id %>">
<%= svg_icon('x', class: 'w-5 h-5') %> <button id="decline_button_mobile" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
</label> <%= svg_icon('x', class: 'w-5 h-5') %>
</button>
</modal-button>
<% end %> <% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2"> <download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton"> <span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %> <%= svg_icon('download', class: 'w-5 h-5') %>
</span> </span>
@@ -82,16 +93,18 @@
</download-button> </download-button>
<% else %> <% else %>
<% if @form_configs[:with_decline] %> <% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-0"> <modal-button data-target="<%= decline_modal_id %>">
<span class="min-[1366px]:inline hidden px-3"> <button id="decline_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:decline) %>">
<%= t(:decline) %> <span class="min-[1366px]:inline hidden px-3">
</span> <%= t(:decline) %>
<span class="inline min-[1366px]:hidden px-2"> </span>
<%= svg_icon('x', class: 'w-5 h-5') %> <span class="inline min-[1366px]:hidden px-2">
</span> <%= svg_icon('x', class: 'w-5 h-5') %>
</label> </span>
</button>
</modal-button>
<% end %> <% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2"> <download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton"> <span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %> <%= svg_icon('download', class: 'w-5 h-5') %>
</span> </span>
@@ -105,13 +118,14 @@
<% schema.each do |item| %> <% schema.each do |item| %>
<% document = @submitter.submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %> <% document = @submitter.submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<div id="document-<%= document.uuid %>"> <div id="document-<%= document.uuid %>">
<div class="sr_only_content"></div>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %> <% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %> <% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %> <% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %> <% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %> <% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}#{Templates::ProcessDocument::PREVIEW_FORMAT}")) %>
<page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>"> <page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>"> <img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>" alt="<%= "#{item['name']} - #{t('page')} #{index + 1}" %>">
<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">
<% if annots = document_annots_index[index] %> <% if annots = document_annots_index[index] %>
<%= render 'submit_form/annotations', annots: %> <%= render 'submit_form/annotations', annots: %>
@@ -140,7 +154,7 @@
</div> </div>
<% end %> <% end %>
</div> </div>
</div> </main>
</div> </div>
<div class="fixed bottom-0 w-full h-0 z-50"> <div class="fixed bottom-0 w-full h-0 z-50">
<div class="mx-auto" style="max-width: 1000px"> <div class="mx-auto" style="max-width: 1000px">
@@ -150,12 +164,12 @@
</div> </div>
</div> </div>
<% if @form_configs[:with_decline] %> <% if @form_configs[:with_decline] %>
<%= render 'shared/html_modal', title: t(:decline), uuid: decline_modal_checkbox_uuid do %> <%= render 'shared/html_modal', title: t(:decline), uuid: decline_modal_id do %>
<%= render 'submit_form/decline_form', submitter: @submitter %> <%= render 'submit_form/decline_form', submitter: @submitter %>
<% end %> <% end %>
<% end %> <% end %>
<% if @form_configs[:with_delegate] %> <% if @form_configs[:with_delegate] %>
<%= render 'shared/html_modal', title: t(:delegate), uuid: delegate_modal_checkbox_uuid do %> <%= render 'shared/html_modal', title: t(:delegate), uuid: delegate_modal_id do %>
<%= render 'submit_form/delegate_form', submitter: @submitter %> <%= render 'submit_form/delegate_form', submitter: @submitter %>
<% end %> <% end %>
<% end %> <% end %>
+8 -1
View File
@@ -23,7 +23,14 @@
</label> </label>
<% end %> <% end %>
<div class="flex gap-2 mt-3"> <div class="flex gap-2 mt-3">
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug, host: form_link_host) %>" class="base-input w-full" autocomplete="off" readonly> <div class="relative flex-grow">
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug, host: form_link_host) %>" class="base-input w-full pr-10" autocomplete="off" readonly>
<% if @template.variables_schema.blank? && !@template.preferences&.dig('require_email_2fa') && !@template.preferences&.dig('require_phone_2fa') %>
<a href="<%= template_share_link_qr_path(@template) %>" target="_blank" rel="noopener" class="absolute top-1/2 -translate-y-1/2 right-2 flex items-center justify-center tooltip text-base-content/70 hover:text-base-content bg-white rounded px-1 py-0.5" data-tip="<%= t('qr_code') %>" aria-label="<%= t('qr_code') %>">
<%= svg_icon('qrcode', class: 'w-6 h-6') %>
</a>
<% end %>
</div>
<check-on-click data-element-id="template_shared_link"> <check-on-click data-element-id="template_shared_link">
<%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug, host: form_link_host), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> <%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug, host: form_link_host), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</check-on-click> </check-on-click>
@@ -0,0 +1,2 @@
<%= t('powered_by') %>
<a href="<%= Docuseal::PRODUCT_URL %>" target="_blank" rel="noopener"><%= Docuseal.product_name %></a>
@@ -0,0 +1,2 @@
<%= render 'shared/logo' %>
<span><%= Docuseal.product_name %></span>
@@ -0,0 +1,10 @@
<div class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4">
<p class="text-xl font-semibold text-center">
<%= t('share_link_is_currently_disabled') %>
</p>
<% if can?(:update, @template) %>
<toggle-submit class="block">
<%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: template_share_link_qr_path(@template) }, method: :post, data: { turbo: false }, class: 'base-button w-full' %>
</toggle-submit>
<% end %>
</div>
@@ -0,0 +1,288 @@
<!DOCTYPE html>
<% page_width_css = @page_size == 'Letter' ? 8.5 * 96.0 : 210.0 * 96.0 / 25.4 %>
<% page_height_css = @page_size == 'Letter' ? 11.0 * 96.0 : 297.0 * 96.0 / 25.4 %>
<% page_width = @page_size == 'Letter' ? '8.5in' : '210mm' %>
<% page_cqw = ->(px) { format('%.6fcqw', px / page_width_css * 100.0) } %>
<html lang="<%= I18n.locale %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= @template.name %></title>
<style>
@page {
size: <%= @page_size %> portrait;
margin: 0.5in;
}
html, body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: #111;
background: #faf7f5;
}
.qr-page-wrapper {
container-type: size;
width: min(100vw, <%= page_width %>);
max-width: 100%;
aspect-ratio: <%= format('%<width>.6f / %<height>.6f', width: page_width_css, height: page_height_css) %>;
margin: 24px auto;
}
.qr-page {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: <%= page_cqw.call(72) %>;
background: #ffffff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
display: grid;
grid-template-rows: auto 1fr auto;
}
.qr-logo {
display: flex;
align-items: center;
justify-content: center;
gap: <%= page_cqw.call(10) %>;
font-size: <%= page_cqw.call(20) %>;
font-weight: 700;
letter-spacing: -0.01em;
}
.qr-logo svg {
width: <%= page_cqw.call(32) %>;
height: <%= page_cqw.call(32) %>;
}
.qr-logo img {
height: <%= page_cqw.call(50) %>;
}
.qr-content {
align-self: center;
text-align: center;
min-width: 0;
margin-bottom: <%= page_cqw.call(80) %>;
}
.qr-header {
font-size: <%= page_cqw.call(36) %>;
font-weight: 700;
line-height: 1.2;
padding: 0 <%= page_cqw.call(8) %>;
margin-bottom: <%= page_cqw.call(48) %>;
word-break: break-word;
}
.qr-main svg {
display: block;
width: <%= page_cqw.call(480) %>;
height: auto;
max-width: 100%;
margin: 0 auto;
shape-rendering: crispEdges;
}
.qr-footer {
font-size: <%= page_cqw.call(22) %>;
line-height: 1.4;
padding: 0 <%= page_cqw.call(8) %>;
margin-top: <%= page_cqw.call(48) %>;
word-break: break-word;
}
.qr-branding {
align-self: end;
text-align: center;
font-size: <%= page_cqw.call(11) %>;
color: #6b7280;
padding-top: <%= page_cqw.call(24) %>;
}
.qr-branding a {
color: #4b5563;
text-decoration: none;
}
[contenteditable="true"] {
outline: 1px dashed #cbd5e1;
outline-offset: 6px;
cursor: text;
transition: outline-color 0.15s ease;
}
[contenteditable="true"]:hover {
outline-color: #94a3b8;
}
[contenteditable="true"]:focus {
outline: 1px dashed #291334;
outline-offset: 6px;
}
.print-button {
position: fixed;
right: 24px;
top: 24px;
z-index: 100;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 3rem;
height: 3rem;
padding-left: 1rem;
padding-right: 1rem;
border: 1px solid #291334;
border-radius: 1.9rem;
background-color: #291334;
color: #ffffff;
font-size: 1rem;
font-weight: 500;
line-height: 1em;
font-family: inherit;
text-transform: none;
letter-spacing: normal;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
}
.print-button:hover {
background-color: #1a0c22;
border-color: #1a0c22;
}
.print-button:active {
transform: scale(0.97);
}
.print-button:focus-visible {
outline: 2px solid #291334;
outline-offset: 2px;
}
.print-button svg {
width: 1.25rem;
height: 1.25rem;
}
@media screen and (max-width: 820px) {
.print-button {
top: auto;
right: 12px;
bottom: 12px;
}
}
@media print {
html, body {
background: #ffffff;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
.qr-page-wrapper {
width: 100%;
height: 100%;
aspect-ratio: auto;
margin: 0;
}
.qr-page {
margin: 0;
box-shadow: none;
width: 100%;
height: 100%;
min-height: 100%;
padding: 0.25in;
page-break-inside: avoid;
page-break-after: avoid;
}
.qr-logo {
gap: 10px;
font-size: 20px;
}
.qr-logo svg {
width: 32px;
height: 32px;
}
.qr-logo img {
height: 50px;
}
.qr-content {
margin-bottom: 80px;
}
.qr-header {
font-size: 36px;
padding: 0 8px;
margin-bottom: 48px;
}
.qr-main svg {
width: 5in;
height: 5in;
}
.qr-footer {
font-size: 22px;
padding: 0 8px;
margin-top: 48px;
}
.qr-branding {
font-size: 11px;
padding-top: 24px;
}
.print-button {
display: none !important;
}
[contenteditable="true"],
[contenteditable="true"]:focus {
outline: none !important;
}
}
</style>
</head>
<body>
<div class="qr-page-wrapper">
<div class="qr-page">
<div class="qr-logo">
<%= render 'logo' %>
</div>
<div class="qr-content">
<div class="qr-header" contenteditable="true" spellcheck="false"><%= @template.name %></div>
<div class="qr-main">
<%== @qr_svg_code %>
</div>
<div class="qr-footer" contenteditable="true" spellcheck="false">
<%= t('scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document') %>
</div>
</div>
<div class="qr-branding">
<%= render 'branding' %>
</div>
</div>
</div>
<button type="button" id="qr-print-button" class="print-button">
<%= svg_icon('printer') %>
<span><%= t('print') %></span>
</button>
<script nonce="<%= content_security_policy_nonce %>">
document.getElementById('qr-print-button').addEventListener('click', function () {
window.print();
});
</script>
</body>
</html>
+16 -6
View File
@@ -8,11 +8,21 @@
<%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> <%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</div> </div>
</div> </div>
<%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> <% if @webhook_url.new_record? %>
<%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %> <%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<div class="space-y-2 md:flex-nowrap mt-2"> <%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
<%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook' %> <div class="space-y-2 md:flex-nowrap mt-2">
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %> <%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook' %>
</div> <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %>
</div>
<% end %>
<% else %>
<%= form_for @webhook_url, url: settings_webhook_path(@webhook_url), method: :put, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
<div class="space-y-2 md:flex-nowrap mt-2">
<%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook', required: true %>
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %>
</div>
<% end %>
<% end %> <% end %>
<% end %> <% end %>
+42
View File
@@ -162,6 +162,7 @@ en: &en
download_documents: Download documents download_documents: Download documents
downloading: Downloading downloading: Downloading
download: Download download: Download
page: Page
decline: Decline decline: Decline
declined: Declined declined: Declined
delegate: Delegate delegate: Delegate
@@ -367,6 +368,9 @@ en: &en
sign_out: Sign out sign_out: Sign out
page_number: 'Page %{number}' page_number: 'Page %{number}'
powered_by: Powered by powered_by: Powered by
qr_code: QR Code
print: Print
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scan the QR code above with your phone camera to open and sign this document.
count_documents_signed_with_html: '<b>%{count}</b> documents signed with' count_documents_signed_with_html: '<b>%{count}</b> documents signed with'
storage: Storage storage: Storage
notifications: Notifications notifications: Notifications
@@ -938,6 +942,7 @@ en: &en
embed: Embedding embed: Embedding
invite: Invite invite: Invite
link: Link link: Link
mcp: MCP
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}'
bounce_email_html: '<b>Email bounced</b> %{submitter_name}' bounce_email_html: '<b>Email bounced</b> %{submitter_name}'
@@ -1201,6 +1206,7 @@ es: &es
download_documents: Descargar documentos download_documents: Descargar documentos
downloading: Descargando downloading: Descargando
download: Descargar download: Descargar
page: Página
decline: Rechazar decline: Rechazar
delegate: Delegar delegate: Delegar
enter_the_email_address_of_the_person_you_want_to_delegate_to: Ingrese la dirección de correo electrónico de la persona a quien desea delegar enter_the_email_address_of_the_person_you_want_to_delegate_to: Ingrese la dirección de correo electrónico de la persona a quien desea delegar
@@ -1406,6 +1412,9 @@ es: &es
sign_out: Cerrar sesión sign_out: Cerrar sesión
page_number: 'Página %{number}' page_number: 'Página %{number}'
powered_by: Desarrollado por powered_by: Desarrollado por
qr_code: Código QR
print: Imprimir
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Escanea el código QR de arriba con la cámara de tu teléfono para abrir y firmar este documento.
count_documents_signed_with_html: '<b>%{count}</b> documentos firmados con' count_documents_signed_with_html: '<b>%{count}</b> documentos firmados con'
storage: Almacenamiento storage: Almacenamiento
notifications: Notificaciones notifications: Notificaciones
@@ -1974,6 +1983,7 @@ es: &es
embed: Integración embed: Integración
invite: Invitación invite: Invitación
link: Enlace link: Enlace
mcp: MCP
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}'
bounce_email_html: '<b>Correo electrónico rebotado</b> %{submitter_name}' bounce_email_html: '<b>Correo electrónico rebotado</b> %{submitter_name}'
@@ -2237,6 +2247,7 @@ it: &it
download_documents: Scarica documenti download_documents: Scarica documenti
downloading: Scaricamento downloading: Scaricamento
download: Scarica download: Scarica
page: Pagina
decline: Rifiuta decline: Rifiuta
delegate: Delega delegate: Delega
enter_the_email_address_of_the_person_you_want_to_delegate_to: Inserisci l'indirizzo email della persona a cui vuoi delegare enter_the_email_address_of_the_person_you_want_to_delegate_to: Inserisci l'indirizzo email della persona a cui vuoi delegare
@@ -2442,6 +2453,9 @@ it: &it
sign_out: Esci sign_out: Esci
page_number: 'Pagina %{number}' page_number: 'Pagina %{number}'
powered_by: Fornito da powered_by: Fornito da
qr_code: Codice QR
print: Stampa
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scansiona il codice QR qui sopra con la fotocamera del tuo telefono per aprire e firmare questo documento.
count_documents_signed_with_html: '<b>%{count}</b> documenti firmati con' count_documents_signed_with_html: '<b>%{count}</b> documenti firmati con'
storage: Archiviazione storage: Archiviazione
notifications: Notifiche notifications: Notifiche
@@ -3011,6 +3025,7 @@ it: &it
embed: Incorporamento embed: Incorporamento
invite: Invito invite: Invito
link: Link link: Link
mcp: MCP
submission_event_names: submission_event_names:
send_email_to_html: '<b>Email inviata</b> a %{submitter_name}' send_email_to_html: '<b>Email inviata</b> a %{submitter_name}'
bounce_email_html: '<b>Email respinta</b> %{submitter_name}' bounce_email_html: '<b>Email respinta</b> %{submitter_name}'
@@ -3274,6 +3289,7 @@ fr: &fr
download_documents: Télécharger les documents download_documents: Télécharger les documents
downloading: Téléchargement downloading: Téléchargement
download: Télécharger download: Télécharger
page: Page
decline: Refuser decline: Refuser
delegate: Déléguer delegate: Déléguer
enter_the_email_address_of_the_person_you_want_to_delegate_to: Saisissez l'adresse e-mail de la personne à qui vous souhaitez déléguer enter_the_email_address_of_the_person_you_want_to_delegate_to: Saisissez l'adresse e-mail de la personne à qui vous souhaitez déléguer
@@ -3479,6 +3495,9 @@ fr: &fr
sign_out: Se déconnecter sign_out: Se déconnecter
page_number: Page %{number} page_number: Page %{number}
powered_by: Propulsé par powered_by: Propulsé par
qr_code: Code QR
print: Imprimer
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scannez le code QR ci-dessus avec l'appareil photo de votre téléphone pour ouvrir et signer ce document.
count_documents_signed_with_html: "<b>%{count}</b> documents signés avec" count_documents_signed_with_html: "<b>%{count}</b> documents signés avec"
storage: Stockage storage: Stockage
notifications: Notifications notifications: Notifications
@@ -4044,6 +4063,7 @@ fr: &fr
embed: Embedding embed: Embedding
invite: Invitation invite: Invitation
link: Lien link: Lien
mcp: MCP
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}"
bounce_email_html: "<b>E‑mail rejeté</b> %{submitter_name}" bounce_email_html: "<b>E‑mail rejeté</b> %{submitter_name}"
@@ -4307,6 +4327,7 @@ pt: &pt
download_documents: Baixar documentos download_documents: Baixar documentos
downloading: Baixando downloading: Baixando
download: Baixar download: Baixar
page: Página
decline: Recusar decline: Recusar
delegate: Delegar delegate: Delegar
enter_the_email_address_of_the_person_you_want_to_delegate_to: Insira o endereço de e-mail da pessoa para quem deseja delegar enter_the_email_address_of_the_person_you_want_to_delegate_to: Insira o endereço de e-mail da pessoa para quem deseja delegar
@@ -4512,6 +4533,9 @@ pt: &pt
sign_out: Sair sign_out: Sair
page_number: 'Página %{number}' page_number: 'Página %{number}'
powered_by: Desenvolvido por powered_by: Desenvolvido por
qr_code: Código QR
print: Imprimir
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Escaneie o código QR acima com a câmera do seu telefone para abrir e assinar este documento.
count_documents_signed_with_html: '<b>%{count}</b> documentos assinados com' count_documents_signed_with_html: '<b>%{count}</b> documentos assinados com'
storage: Armazenamento storage: Armazenamento
notifications: Notificações notifications: Notificações
@@ -5080,6 +5104,7 @@ pt: &pt
embed: Incorporação embed: Incorporação
invite: Convite invite: Convite
link: Link link: Link
mcp: MCP
submission_event_names: submission_event_names:
send_email_to_html: '<b>Email enviado</b> para %{submitter_name}' send_email_to_html: '<b>Email enviado</b> para %{submitter_name}'
bounce_email_html: '<b>Email não entregue</b> %{submitter_name}' bounce_email_html: '<b>Email não entregue</b> %{submitter_name}'
@@ -5343,6 +5368,7 @@ de: &de
download_documents: Dokumente herunterladen download_documents: Dokumente herunterladen
downloading: Wird heruntergeladen downloading: Wird heruntergeladen
download: Download download: Download
page: Seite
decline: Ablehnen decline: Ablehnen
delegate: Delegieren delegate: Delegieren
enter_the_email_address_of_the_person_you_want_to_delegate_to: Geben Sie die E-Mail-Adresse der Person ein, an die Sie delegieren möchten enter_the_email_address_of_the_person_you_want_to_delegate_to: Geben Sie die E-Mail-Adresse der Person ein, an die Sie delegieren möchten
@@ -5548,6 +5574,9 @@ de: &de
sign_out: Abmelden sign_out: Abmelden
page_number: 'Seite %{number}' page_number: 'Seite %{number}'
powered_by: Bereitgestellt von powered_by: Bereitgestellt von
qr_code: QR-Code
print: Drucken
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scannen Sie den QR-Code oben mit Ihrer Handykamera, um dieses Dokument zu öffnen und zu unterzeichnen.
count_documents_signed_with_html: '<b>%{count}</b> Dokumente signiert mit' count_documents_signed_with_html: '<b>%{count}</b> Dokumente signiert mit'
storage: Speicher storage: Speicher
notifications: Benachrichtigungen notifications: Benachrichtigungen
@@ -6116,6 +6145,7 @@ de: &de
embed: Einbettung embed: Einbettung
invite: Einladung invite: Einladung
link: Link link: Link
mcp: MCP
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}'
bounce_email_html: '<b>E-Mail unzustellbar</b> %{submitter_name}' bounce_email_html: '<b>E-Mail unzustellbar</b> %{submitter_name}'
@@ -6243,6 +6273,7 @@ pl:
view: Widok view: Widok
hi_there: Cześć, hi_there: Cześć,
download: Pobierz download: Pobierz
page: Strona
decline: Odrzuć decline: Odrzuć
delegate: Deleguj delegate: Deleguj
enter_the_email_address_of_the_person_you_want_to_delegate_to: Wprowadź adres e-mail osoby, do której chcesz delegować enter_the_email_address_of_the_person_you_want_to_delegate_to: Wprowadź adres e-mail osoby, do której chcesz delegować
@@ -6343,6 +6374,7 @@ uk:
view: Переглянути view: Переглянути
hi_there: Привіт, hi_there: Привіт,
download: Завантажити download: Завантажити
page: Сторінка
decline: Відхилити decline: Відхилити
delegate: Делегувати delegate: Делегувати
enter_the_email_address_of_the_person_you_want_to_delegate_to: Введіть адресу електронної пошти особи, якій ви хочете делегувати enter_the_email_address_of_the_person_you_want_to_delegate_to: Введіть адресу електронної пошти особи, якій ви хочете делегувати
@@ -6443,6 +6475,7 @@ cs:
view: Zobrazit view: Zobrazit
hi_there: Ahoj, hi_there: Ahoj,
download: Stáhnout download: Stáhnout
page: Stránka
decline: Odmítnout decline: Odmítnout
delegate: Delegovat delegate: Delegovat
enter_the_email_address_of_the_person_you_want_to_delegate_to: Zadejte e-mailovou adresu osoby, na kterou chcete delegovat enter_the_email_address_of_the_person_you_want_to_delegate_to: Zadejte e-mailovou adresu osoby, na kterou chcete delegovat
@@ -6543,6 +6576,7 @@ he:
view: תצוגה view: תצוגה
hi_there: שלום, hi_there: שלום,
download: הורד download: הורד
page: עמוד
decline: דחייה decline: דחייה
delegate: הואלה delegate: הואלה
enter_the_email_address_of_the_person_you_want_to_delegate_to: הזן את כתובת הדוא"ל של האדם שברצונך להאציל אליו enter_the_email_address_of_the_person_you_want_to_delegate_to: הזן את כתובת הדוא"ל של האדם שברצונך להאציל אליו
@@ -6780,6 +6814,7 @@ nl: &nl
download_documents: Documenten downloaden download_documents: Documenten downloaden
downloading: Downloaden downloading: Downloaden
download: Downloaden download: Downloaden
page: Pagina
decline: Weigeren decline: Weigeren
delegate: Delegeren delegate: Delegeren
enter_the_email_address_of_the_person_you_want_to_delegate_to: Voer het e-mailadres in van de persoon aan wie u wilt delegeren enter_the_email_address_of_the_person_you_want_to_delegate_to: Voer het e-mailadres in van de persoon aan wie u wilt delegeren
@@ -6985,6 +7020,9 @@ nl: &nl
sign_out: Afmelden sign_out: Afmelden
page_number: Pagina %{number} page_number: Pagina %{number}
powered_by: Aangedreven door powered_by: Aangedreven door
qr_code: QR-code
print: Afdrukken
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scan de bovenstaande QR-code met je telefooncamera om dit document te openen en te ondertekenen.
count_documents_signed_with_html: "<b>%{count}</b> documenten ondertekend met" count_documents_signed_with_html: "<b>%{count}</b> documenten ondertekend met"
storage: Opslag storage: Opslag
notifications: Meldingen notifications: Meldingen
@@ -7549,6 +7587,7 @@ nl: &nl
embed: Insluiten embed: Insluiten
invite: Uitnodiging invite: Uitnodiging
link: Link link: Link
mcp: MCP
submission_event_names: submission_event_names:
send_email_to_html: "<b>E-mail verzonden</b> naar %{submitter_name}" send_email_to_html: "<b>E-mail verzonden</b> naar %{submitter_name}"
bounce_email_html: "<b>E-mail gebounced</b> %{submitter_name}" bounce_email_html: "<b>E-mail gebounced</b> %{submitter_name}"
@@ -7676,6 +7715,7 @@ ar:
view: عرض view: عرض
hi_there: مرحبا, hi_there: مرحبا,
download: تحميل download: تحميل
page: صفحة
decline: رفض decline: رفض
delegate: تفويض delegate: تفويض
enter_the_email_address_of_the_person_you_want_to_delegate_to: أدخل عنوان البريد الإلكتروني للشخص الذي تريد التفويض إليه enter_the_email_address_of_the_person_you_want_to_delegate_to: أدخل عنوان البريد الإلكتروني للشخص الذي تريد التفويض إليه
@@ -7776,6 +7816,7 @@ ko:
view: 보기 view: 보기
hi_there: 안녕하세요, hi_there: 안녕하세요,
download: 다운로드 download: 다운로드
page: 페이지
decline: 거절 decline: 거절
delegate: 위임 delegate: 위임
enter_the_email_address_of_the_person_you_want_to_delegate_to: 위임할 사람의 이메일 주소를 입력하세요 enter_the_email_address_of_the_person_you_want_to_delegate_to: 위임할 사람의 이메일 주소를 입력하세요
@@ -7876,6 +7917,7 @@ ja:
view: 表示 view: 表示
hi_there: こんにちは hi_there: こんにちは
download: ダウンロード download: ダウンロード
page: ページ
decline: 辞退 decline: 辞退
delegate: 委任 delegate: 委任
enter_the_email_address_of_the_person_you_want_to_delegate_to: 委任したい人のメールアドレスを入力してください enter_the_email_address_of_the_person_you_want_to_delegate_to: 委任したい人のメールアドレスを入力してください
+3 -1
View File
@@ -77,7 +77,7 @@ Rails.application.routes.draw do
resources :console_redirect, only: %i[index] resources :console_redirect, only: %i[index]
resources :upgrade, only: %i[index], controller: 'console_redirect' resources :upgrade, only: %i[index], controller: 'console_redirect'
resources :manage, only: %i[index], controller: 'console_redirect' resources :manage, only: %i[index], controller: 'console_redirect'
resource :testing_account, only: %i[show destroy] resource :testing_account, only: %i[create 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 :submitters_resubmit, only: %i[update]
@@ -108,6 +108,7 @@ Rails.application.routes.draw do
resource :code_modal, only: %i[show], controller: 'templates_code_modal' resource :code_modal, only: %i[show], controller: 'templates_code_modal'
resource :preferences, only: %i[show create destroy], controller: 'templates_preferences' resource :preferences, only: %i[show create destroy], controller: 'templates_preferences'
resource :share_link, only: %i[show create], controller: 'templates_share_link' resource :share_link, only: %i[show create], controller: 'templates_share_link'
resource :share_link_qr, only: %i[show], controller: 'templates_share_link_qr'
resources :recipients, only: %i[create], controller: 'templates_recipients' resources :recipients, only: %i[create], controller: 'templates_recipients'
resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields' resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields'
resources :submissions_export, only: %i[index new] resources :submissions_export, only: %i[index new]
@@ -149,6 +150,7 @@ Rails.application.routes.draw do
resources :decline, only: %i[create], controller: 'submit_form_decline' resources :decline, only: %i[create], controller: 'submit_form_decline'
resources :delegate, only: %i[create], controller: 'submit_form_delegate' resources :delegate, only: %i[create], controller: 'submit_form_delegate'
resources :invite, only: %i[create], controller: 'submit_form_invite' resources :invite, only: %i[create], controller: 'submit_form_invite'
resources :metadata, only: %i[index], controller: 'submit_form_metadata'
resources :debug, only: %i[index], controller: 'submissions_debug' if Rails.env.development? resources :debug, only: %i[index], controller: 'submissions_debug' if Rails.env.development?
get :completed get :completed
get :delegated get :delegated
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateDocumentMetadata < ActiveRecord::Migration[8.1]
def change
create_table :document_metadata do |t|
t.references :account, null: false, foreign_key: true, index: false
t.string :blob_checksum, null: false
t.text :text_runs, null: false
t.datetime :created_at, null: false
end
add_index :document_metadata, %i[account_id blob_checksum], unique: true
end
end
+10 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "btree_gin" enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@@ -168,6 +168,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id" t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end end
create_table "document_metadata", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "blob_checksum", null: false
t.datetime "created_at", null: false
t.text "text_runs", null: false
t.index ["account_id", "blob_checksum"], name: "index_document_metadata_on_account_id_and_blob_checksum", unique: true
end
create_table "dynamic_document_versions", force: :cascade do |t| create_table "dynamic_document_versions", force: :cascade do |t|
t.text "areas", null: false t.text "areas", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -553,6 +561,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters" add_foreign_key "document_generation_events", "submitters"
add_foreign_key "document_metadata", "accounts"
add_foreign_key "dynamic_document_versions", "dynamic_documents" add_foreign_key "dynamic_document_versions", "dynamic_documents"
add_foreign_key "dynamic_documents", "templates" add_foreign_key "dynamic_documents", "templates"
add_foreign_key "email_events", "accounts" add_foreign_key "email_events", "accounts"
+34
View File
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module DocumentMetadatas
module_function
def find_or_create_for_document(document, account_id:)
checksum = document.blob.checksum
metadata = DocumentMetadata.find_by(account_id:, blob_checksum: checksum)
metadata ||= DocumentMetadata.create!(account_id:, blob_checksum: checksum, text_runs: build_text_runs(document))
metadata
rescue ActiveRecord::RecordNotUnique
retry
end
def build_text_runs(document)
number_of_pages = document.metadata.dig('pdf', 'number_of_pages').to_i
return {} if number_of_pages.zero?
Pdfium::Document.open_bytes(document.download) do |doc|
(0...doc.page_count).each_with_object({}) do |page_index, acc|
page = doc.get_page(page_index)
acc[page_index] = page.text_objects.map do |node|
{ text: node.content, x: node.x, y: node.y, w: node.w, h: node.h, font_size: node.font_size }
end
ensure
page&.close
end
end
end
end
-1
View File
@@ -1,7 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
module Docuseal module Docuseal
URL_CACHE = ActiveSupport::Cache::MemoryStore.new
PRODUCT_URL = 'https://www.docuseal.com' PRODUCT_URL = 'https://www.docuseal.com'
PRODUCT_EMAIL_URL = ENV.fetch('PRODUCT_EMAIL_URL', PRODUCT_URL) PRODUCT_EMAIL_URL = ENV.fetch('PRODUCT_EMAIL_URL', PRODUCT_URL)
NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze
+1
View File
@@ -4,6 +4,7 @@ module Mcp
module HandleRequest module HandleRequest
TOOLS = [ TOOLS = [
Mcp::Tools::SearchTemplates, Mcp::Tools::SearchTemplates,
Mcp::Tools::LoadTemplate,
Mcp::Tools::CreateTemplate, Mcp::Tools::CreateTemplate,
Mcp::Tools::SendDocuments, Mcp::Tools::SendDocuments,
Mcp::Tools::SearchDocuments Mcp::Tools::SearchDocuments
+41 -49
View File
@@ -6,33 +6,28 @@ module Mcp
SCHEMA = { SCHEMA = {
name: 'create_template', name: 'create_template',
title: 'Create Template', title: 'Create Template',
description: 'Create a template from a PDF. Provide a URL or base64-encoded file content.', description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \
'to create an empty template and receive an edit URL where the file can be uploaded via the UI.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
url: {
type: 'string',
description: 'URL of the document file to upload'
},
file: {
type: 'string',
description: 'Base64-encoded file content'
},
filename: {
type: 'string',
description: 'Filename with extension (required when using file)'
},
name: { name: {
type: 'string', type: 'string',
description: 'Template name (defaults to filename)' description: 'Template name (used as the template name and required when url is not provided)'
},
url: {
type: 'string',
description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \
'created and the returned edit_url can be used to upload a file via the UI.'
} }
} },
required: %w[name]
}, },
annotations: { annotations: {
readOnlyHint: false, readOnlyHint: false,
destructiveHint: false, destructiveHint: false,
idempotentHint: false, idempotentHint: false,
openWorldHint: false openWorldHint: true
} }
}.freeze }.freeze
@@ -44,48 +39,45 @@ module Mcp
account = current_user.account account = current_user.account
if arguments['file'].present? template = Template.new(
tempfile = Tempfile.new account:,
tempfile.binmode author: current_user,
tempfile.write(Base64.decode64(arguments['file'])) folder: account.default_template_folder,
tempfile.rewind source: :mcp,
name: arguments['name'].to_s.presence || 'New Template',
fields: [],
schema: []
)
filename = arguments['filename'] || 'document.pdf' if arguments['url'].present?
elsif arguments['url'].present?
tempfile = Tempfile.new tempfile = Tempfile.new
tempfile.binmode tempfile.binmode
tempfile.write(DownloadUtils.call(arguments['url'], validate: true).body) tempfile.write(DownloadUtils.call(arguments['url'], validate: true).body)
tempfile.rewind tempfile.rewind
filename = File.basename(URI.decode_www_form_component(arguments['url'])) filename = File.basename(URI.decode_www_form_component(arguments['url']))
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: Marcel::MimeType.for(tempfile)
)
template.name = arguments['name'].presence || File.basename(filename, '.*')
template.save!
documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if template.fields.blank?
template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents)
end
template.update!(schema:)
else else
return { content: [{ type: 'text', text: 'Provide either url or file' }], isError: true } template.save!
end end
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: Marcel::MimeType.for(tempfile)
)
template = Template.new(
account:,
author: current_user,
folder: account.default_template_folder,
name: arguments['name'].presence || File.basename(filename, '.*')
)
template.save!
documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if template.fields.blank?
template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents)
end
template.update!(schema:)
WebhookUrls.enqueue_events(template, 'template.created') WebhookUrls.enqueue_events(template, 'template.created')
SearchEntries.enqueue_reindex(template) SearchEntries.enqueue_reindex(template)
@@ -104,7 +96,7 @@ module Mcp
] ]
} }
end end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end end
end end
+67
View File
@@ -0,0 +1,67 @@
# frozen_string_literal: true
module Mcp
module Tools
module LoadTemplate
SCHEMA = {
name: 'load_template',
title: 'Load Template',
description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
inputSchema: {
type: 'object',
properties: {
template_id: {
type: 'integer',
description: 'Template identifier'
}
},
required: %w[template_id]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}.freeze
module_function
def call(arguments, _current_user, current_ability)
template = Template.accessible_by(current_ability).find_by(id: arguments['template_id'])
return { content: [{ type: 'text', text: 'Template not found' }], isError: true } unless template
current_ability.authorize!(:read, template)
submitters_index = template.submitters.index_by { |s| s['uuid'] }
roles = template.submitters.pluck('name')
fields = template.fields.filter_map do |field|
next if field['name'].blank?
{
name: field['name'],
type: field['type'],
role: submitters_index[field['submitter_uuid']]&.dig('name')
}
end
{
content: [
{
type: 'text',
text: {
id: template.id,
name: template.name,
roles: roles,
fields: fields
}.to_json
}
]
}
end
end
end
end
+34 -5
View File
@@ -31,6 +31,27 @@ module Mcp
phone: { phone: {
type: 'string', type: 'string',
description: 'Submitter phone number in E.164 format' description: 'Submitter phone number in E.164 format'
},
role: {
type: 'string',
description: 'Signing role name from the template'
},
fields: {
type: 'array',
description: 'Prefill field values for this submitter (fields become readonly)',
items: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Field name'
},
value: {
description: 'Prefilled value for the field'
}
},
required: %w[name value]
}
} }
} }
} }
@@ -40,7 +61,7 @@ module Mcp
}, },
annotations: { annotations: {
readOnlyHint: false, readOnlyHint: false,
destructiveHint: false, destructiveHint: true,
idempotentHint: false, idempotentHint: false,
openWorldHint: true openWorldHint: true
} }
@@ -59,15 +80,23 @@ module Mcp
return { content: [{ type: 'text', text: 'Template has no fields' }], isError: true } if template.fields.blank? return { content: [{ type: 'text', text: 'Template has no fields' }], isError: true } if template.fields.blank?
submitters = (arguments['submitters'] || []).map do |s| submitters = (arguments['submitters'] || []).map do |s|
s.slice('email', 'name', 'role', 'phone') attrs = s.slice('email', 'name', 'role', 'phone').compact_blank
.compact_blank
.with_indifferent_access fields = Array.wrap(s['fields']).filter_map do |f|
next if f['name'].blank?
{ 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true }
end
attrs['fields'] = fields if fields.present?
attrs.with_indifferent_access
end end
submissions = Submissions.create_from_submitters( submissions = Submissions.create_from_submitters(
template:, template:,
user: current_user, user: current_user,
source: :api, source: :mcp,
submitters_order: 'random', submitters_order: 'random',
submissions_attrs: { submitters: submitters }, submissions_attrs: { submitters: submitters },
params: { 'send_email' => true, 'submitters' => submitters } params: { 'send_email' => true, 'submitters' => submitters }
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more