Compare commits

...

19 Commits

Author SHA1 Message Date
Alex Turchyn eea44bda34
Merge from docusealco/wip
2 weeks ago
Pete Matsyburka 7cdf263da1 add screen reader mode
2 weeks ago
Pete Matsyburka ee65a5693c improve accessibility
2 weeks ago
Pete Matsyburka 0af6ccf35f pipeline field detection
2 weeks ago
Pete Matsyburka 888f1ec6df adjust mcp
3 weeks ago
Pete Matsyburka c95a8616ac deduplicate submitter uuids
3 weeks ago
Pete Matsyburka 5ea6289b7a fix test mode modal
3 weeks ago
Pete Matsyburka d4a79ca5db adjust dynamic editor
3 weeks ago
Pete Matsyburka 70015ce1c4 adjust dynamic editor
3 weeks ago
Pete Matsyburka 6b85c28944 add error message
3 weeks ago
Pete Matsyburka 6c289cf273 optimize build
3 weeks ago
Pete Matsyburka a64bc3c618 change button style
3 weeks ago
Pete Matsyburka 46cf1e3067 add log
3 weeks ago
Pete Matsyburka 565e1eb2bc adjust validation
3 weeks ago
Pete Matsyburka e689687805 fix erblint
3 weeks ago
Pete Matsyburka 3c3b61fb47 adjust reason field
3 weeks ago
Pete Matsyburka 1355d350c5 fix xlsx boolean value
3 weeks ago
Pete Matsyburka fda911e178 add submitter field validation
3 weeks ago
Pete Matsyburka f995e1864c add shared link qr code
3 weeks ago

@ -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

@ -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

@ -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

@ -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

@ -32,9 +32,13 @@ class WebhookSettingsController < ApplicationController
def new; end def new; end
def create def create
if @webhook_url.url.present?
@webhook_url.save! @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

@ -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 () {

@ -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')) {

@ -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>

@ -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'

@ -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=""

@ -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')
}) })
}, },

@ -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,6 +55,7 @@
: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"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
type="date" type="date"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@keydown.enter="onEnter" @keydown.enter="onEnter"

@ -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>

@ -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()
}
})
} }
} }
} }

@ -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')"

@ -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

@ -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">

@ -1097,6 +1097,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 () {

@ -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,

@ -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

@ -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,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,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,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,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

@ -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

@ -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,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 @@
<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,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,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

@ -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,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,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 %>

@ -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>

@ -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? %><%= "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)">
<% 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'].positive? && ((area['w'] * local_assigns[:page_width]).to_f / (area['h'] * 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,6 +56,7 @@
<% 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 %>

@ -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) %>
@ -105,7 +105,7 @@
<% (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}.jpg")) %>
<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: %>
@ -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| %>
@ -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 %>

@ -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">

@ -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 %>

@ -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>

@ -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 %>">
<button id="decline_button" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %> <%= svg_icon('x', class: 'w-5 h-5') %>
</label> </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 %>">
<button id="delegate_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:delegate) %>">
<span class="min-[1366px]:inline hidden px-3"> <span class="min-[1366px]:inline hidden px-3">
<%= t(:delegate) %> <%= t(:delegate) %>
</span> </span>
<span class="inline min-[1366px]:hidden px-2"> <span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('user_share', class: 'w-5 h-5') %> <%= svg_icon('user_share', class: 'w-5 h-5') %>
</span> </span>
</label> </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 %>">
<button id="decline_button_mobile" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %> <%= svg_icon('x', class: 'w-5 h-5') %>
</label> </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 %>">
<button id="decline_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:decline) %>">
<span class="min-[1366px]:inline hidden px-3"> <span class="min-[1366px]:inline hidden px-3">
<%= t(:decline) %> <%= t(:decline) %>
</span> </span>
<span class="inline min-[1366px]:hidden px-2"> <span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('x', class: 'w-5 h-5') %> <%= svg_icon('x', class: 'w-5 h-5') %>
</span> </span>
</label> </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}.jpg")) %>
<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 %>

@ -23,7 +23,12 @@
</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>
<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 tooltip-left 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>
</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>

@ -8,6 +8,7 @@
<%= 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>
<% if @webhook_url.new_record? %>
<%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> <%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %> <%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
<div class="space-y-2 md:flex-nowrap mt-2"> <div class="space-y-2 md:flex-nowrap mt-2">
@ -15,4 +16,13 @@
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %> <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %>
</div> </div>
<% end %> <% 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 %>

@ -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
@ -1201,6 +1205,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 +1411,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
@ -2237,6 +2245,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 +2451,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
@ -3274,6 +3286,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 +3492,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
@ -4307,6 +4323,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 +4529,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
@ -5343,6 +5363,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 +5569,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
@ -6243,6 +6267,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 +6368,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 +6469,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 +6570,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 +6808,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 +7014,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
@ -7676,6 +7708,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 +7809,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 +7910,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: 委任したい人のメールアドレスを入力してください

@ -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,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"

@ -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

@ -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

@ -6,28 +6,23 @@ 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: { name: {
type: 'string',
description: 'URL of the document file to upload'
},
file: {
type: 'string',
description: 'Base64-encoded file content'
},
filename: {
type: 'string', type: 'string',
description: 'Filename with extension (required when using file)' description: 'Template name (used as the template name and required when url is not provided)'
}, },
name: { url: {
type: 'string', type: 'string',
description: 'Template name (defaults to filename)' 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,
@ -44,23 +39,22 @@ 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 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']))
else
return { content: [{ type: 'text', text: 'Provide either url or file' }], isError: true }
end
file = ActionDispatch::Http::UploadedFile.new( file = ActionDispatch::Http::UploadedFile.new(
tempfile:, tempfile:,
@ -68,13 +62,7 @@ module Mcp
type: Marcel::MimeType.for(tempfile) type: Marcel::MimeType.for(tempfile)
) )
template = Template.new( template.name = arguments['name'].presence || File.basename(filename, '.*')
account:,
author: current_user,
folder: account.default_template_folder,
name: arguments['name'].presence || File.basename(filename, '.*')
)
template.save! template.save!
documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true) documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true)
@ -85,6 +73,9 @@ module Mcp
end end
template.update!(schema:) template.update!(schema:)
else
template.save!
end
WebhookUrls.enqueue_events(template, 'template.created') WebhookUrls.enqueue_events(template, 'template.created')
@ -104,7 +95,7 @@ module Mcp
] ]
} }
end end
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end end
end end
end

@ -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

@ -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]
}
} }
} }
} }
@ -59,9 +80,17 @@ 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(

@ -39,6 +39,16 @@ class Pdfium
FPDF_RENDER_FORCEHALFTONE = 0x400 FPDF_RENDER_FORCEHALFTONE = 0x400
FPDF_PRINTING = 0x800 FPDF_PRINTING = 0x800
TextObject = Struct.new(:content, :x, :y, :w, :h, :font_size) do
def endx
@endx ||= x + w
end
def endy
@endy ||= y + h
end
end
TextNode = Struct.new(:content, :x, :y, :w, :h) do TextNode = Struct.new(:content, :x, :y, :w, :h) do
def endx def endx
@endx ||= x + w @endx ||= x + w
@ -117,6 +127,10 @@ class Pdfium
attach_function :FPDFPathSegment_GetType, [:FPDF_PATHSEGMENT], :int attach_function :FPDFPathSegment_GetType, [:FPDF_PATHSEGMENT], :int
attach_function :FPDFPathSegment_GetPoint, %i[FPDF_PATHSEGMENT pointer pointer], :int attach_function :FPDFPathSegment_GetPoint, %i[FPDF_PATHSEGMENT pointer pointer], :int
# Text page object functions (per-run Tj/TJ extraction)
attach_function :FPDFTextObj_GetText, %i[FPDF_PAGEOBJECT FPDF_TEXTPAGE pointer ulong], :ulong
attach_function :FPDFTextObj_GetFontSize, %i[FPDF_PAGEOBJECT pointer], :int
# Page object types # Page object types
FPDF_PAGEOBJ_UNKNOWN = 0 FPDF_PAGEOBJ_UNKNOWN = 0
FPDF_PAGEOBJ_TEXT = 1 FPDF_PAGEOBJ_TEXT = 1
@ -515,6 +529,90 @@ class Pdfium
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null? Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
end end
def text_objects
return @text_objects if @text_objects
ensure_not_closed!
@text_objects = []
object_count = Pdfium.FPDFPage_CountObjects(page_ptr)
return @text_objects if object_count.zero?
text_page = Pdfium.FPDFText_LoadPage(page_ptr)
if text_page.null?
Pdfium.check_last_error("Failed to load text page #{page_index}")
raise PdfiumError, "Failed to load text page #{page_index}, pointer is NULL."
end
left_ptr = FFI::MemoryPointer.new(:float)
bottom_ptr = FFI::MemoryPointer.new(:float)
right_ptr = FFI::MemoryPointer.new(:float)
top_ptr = FFI::MemoryPointer.new(:float)
font_size_ptr = FFI::MemoryPointer.new(:float)
object_count.times do |i|
page_object = Pdfium.FPDFPage_GetObject(page_ptr, i)
next if page_object.null?
next unless Pdfium.FPDFPageObj_GetType(page_object) == Pdfium::FPDF_PAGEOBJ_TEXT
needed_bytes = Pdfium.FPDFTextObj_GetText(page_object, text_page, FFI::Pointer::NULL, 0)
next if needed_bytes < 4
buffer = FFI::MemoryPointer.new(:uint8, needed_bytes)
written = Pdfium.FPDFTextObj_GetText(page_object, text_page, buffer, needed_bytes)
next if written < 4
content = buffer.read_bytes(written - 2).force_encoding('UTF-16LE').encode('UTF-8')
next if content.empty?
next if Pdfium.FPDFPageObj_GetBounds(page_object, left_ptr, bottom_ptr, right_ptr, top_ptr).zero?
obj_left = left_ptr.read_float
obj_bottom = bottom_ptr.read_float
obj_right = right_ptr.read_float
obj_top = top_ptr.read_float
obj_width = obj_right - obj_left
obj_height = obj_top - obj_bottom
next if obj_width <= 0 || obj_height <= 0
font_size =
if Pdfium.FPDFTextObj_GetFontSize(page_object, font_size_ptr) == 0
obj_height
else
font_size_ptr.read_float
end
font_size = 8 if font_size == 1
norm_x = obj_left / width
norm_y = (height - obj_top) / height
norm_w = obj_width / width
norm_h = obj_height / height
@text_objects << TextObject.new(content, norm_x, norm_y, norm_w, norm_h, font_size)
end
y_threshold = 4.0 / width
@text_objects = @text_objects.sort do |a, b|
(a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy
end
ensure
Pdfium.FPDFText_ClosePage(text_page) if text_page && !text_page.null?
end
def line_nodes def line_nodes
return @line_nodes if @line_nodes return @line_nodes if @line_nodes

@ -56,6 +56,8 @@ module Submissions
template_submitter = template_submitters.find { |e| e['uuid'] == uuid } template_submitter = template_submitters.find { |e| e['uuid'] == uuid }
end end
raise BaseError, 'Invalid submitter params' unless template_submitter
template_submitter = template_submitter.except('optional_invite_by_uuid', 'invite_by_uuid', template_submitter = template_submitter.except('optional_invite_by_uuid', 'invite_by_uuid',
'invite_via_field_uuid') 'invite_via_field_uuid')

@ -150,6 +150,8 @@ module Submissions
ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) if attachment ActiveStorage::Blob.proxy_url(attachment.blob, expires_at:) if attachment
end end
elsif submitter_value == true || submitter_value == false
submitter_value.to_s
else else
submitter_value submitter_value
end end

@ -107,24 +107,33 @@ module Submitters
reason_field_uuid = params[:with_reason] reason_field_uuid = params[:with_reason]
signature_field_uuid = values.except(reason_field_uuid).keys.first signature_field_uuid = values.except(reason_field_uuid).keys.first
signature_field = submitter.submission.template_fields.find { |e| e['uuid'] == signature_field_uuid } signature_field = submitter.submission.template_fields.find do |e|
e['uuid'] == signature_field_uuid && e['submitter_uuid'] == submitter.uuid
signature_field['preferences'] ||= {} end
signature_field['preferences']['reason_field_uuid'] = reason_field_uuid
reason_field = submitter.submission.template_fields.find { |e| e['uuid'] == reason_field_uuid } reason_field = submitter.submission.template_fields.find do |e|
e['uuid'] == reason_field_uuid && e['submitter_uuid'] == submitter.uuid
end
unless reason_field if reason_field
if reason_field.dig('preferences', 'signature_field_uuid') != signature_field['uuid']
raise ValidationError, 'Invalid field'
end
else
reason_field = { 'type' => 'text', reason_field = { 'type' => 'text',
'uuid' => reason_field_uuid, 'uuid' => reason_field_uuid,
'name' => I18n.t(:reason), 'name' => I18n.t(:reason),
'readonly' => true, 'readonly' => true,
'preferences' => { 'signature_field_uuid' => signature_field['uuid'] },
'submitter_uuid' => submitter.uuid } 'submitter_uuid' => submitter.uuid }
submitter.submission.template_fields.insert(submitter.submission.template_fields.index(signature_field) + 1, submitter.submission.template_fields.insert(submitter.submission.template_fields.index(signature_field) + 1,
reason_field) reason_field)
end end
signature_field['preferences'] ||= {}
signature_field['preferences']['reason_field_uuid'] = reason_field_uuid
submitter.submission.save! submitter.submission.save!
reason_field reason_field
@ -454,6 +463,9 @@ module Submitters
end end
def validate_value!(_value, field, _params, submitter, _request) def validate_value!(_value, field, _params, submitter, _request)
raise ValidationError, 'Missing field' unless field
raise ValidationError, 'Invalid field' if field['submitter_uuid'] != submitter.uuid
if field['readonly'] == true if field['readonly'] == true
Rollbar.warning("Readonly field #{submitter.id}: #{field['uuid']}") if defined?(Rollbar) Rollbar.warning("Readonly field #{submitter.id}: #{field['uuid']}") if defined?(Rollbar)

@ -114,36 +114,44 @@ module Templates
head_node = PageNode.new(elem: ''.b, page: 0, attachment_uuid: attachment&.uuid) head_node = PageNode.new(elem: ''.b, page: 0, attachment_uuid: attachment&.uuid)
tail_node = head_node tail_node = head_node
page_range = page_number ? [page_number] : (0...doc.page_count) page_indexes = page_number ? [page_number] : (0...doc.page_count).to_a
fields = page_range.flat_map do |current_page_number| prep_opts = { aspect_ratio:, padding:, split_page: }
next [] if current_page_number >= doc.page_count infer_opts = { confidence: confidence / 3.0, nms:, nmm:, temperature: }
page = doc.get_page(current_page_number) image = prepare_page_image(doc.get_page(page_indexes.first), inference:, padding:)
current_args = inference.prepare_input(image, **prep_opts)
current_wait = inference.enqueue(**current_args, **infer_opts)
size_key = page.width > page.height ? :width : :height all_fields = []
size = padding ? inference.resolution * 1.5 : inference.resolution
data, width, height = page.render_to_bitmap(size_key => size) page_indexes.each_with_index do |current_page_number, i|
next_n = page_indexes[i + 1]
image = Vips::Image.new_from_memory(data, width, height, 4, :uchar) next_image = next_n ? prepare_page_image(doc.get_page(next_n), inference:, padding:) : nil
next_args = next_image ? inference.prepare_input(next_image, **prep_opts) : nil
fields = inference.call(image, confidence: confidence / 3.0, nms:, nmm:, split_page:, outputs = current_wait.call
temperature:, aspect_ratio:, padding:)
text_fields = extract_text_fields_from_page(page) next_wait = next_args ? inference.enqueue(**next_args, **infer_opts) : nil
line_fields = extract_line_fields_from_page(page)
fields = sort_fields(fields, y_threshold: 10.0 / image.height) fields = inference.process_outputs(outputs, **current_args, **infer_opts)
current_page = doc.get_page(current_page_number)
fields = sort_fields(fields, y_threshold: 10.0 / current_args[:image].height)
text_fields = extract_text_fields_from_page(current_page)
line_fields = extract_line_fields_from_page(current_page)
fields = increase_confidence_for_overlapping_fields(fields, text_fields, confidence:) fields = increase_confidence_for_overlapping_fields(fields, text_fields, confidence:)
fields = increase_confidence_for_overlapping_fields(fields, line_fields, confidence:) fields = increase_confidence_for_overlapping_fields(fields, line_fields, confidence:)
fields = fields.reject { |f| f.confidence < confidence } fields = fields.reject { |f| f.confidence < confidence }
field_nodes, tail_node = build_page_nodes(page, fields, tail_node, attachment_uuid: attachment&.uuid) field_nodes, tail_node = build_page_nodes(current_page, fields, tail_node, attachment_uuid: attachment&.uuid)
fields = field_nodes.map do |node| page_fields = field_nodes.map do |node|
field = node.elem field = node.elem
type = regexp_type ? type_from_page_node(node) : field.type type = regexp_type ? type_from_page_node(node) : field.type
@ -162,20 +170,32 @@ module Templates
} }
end end
yield [attachment&.uuid, current_page_number, fields] if block_given? yield [attachment&.uuid, current_page_number, page_fields] if block_given?
all_fields.concat(page_fields)
fields current_args = next_args
current_wait = next_wait
ensure ensure
page.close current_page&.close
end end
print_debug(head_node) if Rails.env.development? print_debug(head_node) if Rails.env.development?
[fields, head_node] [all_fields, head_node]
ensure ensure
doc.close doc.close
end end
def prepare_page_image(page, inference:, padding:)
size_key = page.width > page.height ? :width : :height
size = padding ? inference.resolution * 1.5 : inference.resolution
data, width, height = page.render_to_bitmap(size_key => size)
Vips::Image.new_from_memory(data, width, height, 4, :uchar)
end
def sort_fields(fields, y_threshold: 0.01) def sort_fields(fields, y_threshold: 0.01)
fields.sort do |a, b| fields.sort do |a, b|
(a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy (a.endy - b.endy).abs < y_threshold ? a.x <=> b.x : a.endy <=> b.endy

@ -73,6 +73,18 @@ module Templates
build_fields_from_detections(detections, image) build_fields_from_detections(detections, image)
end end
def prepare_input(image, **opts)
{ image:, **opts }
end
def enqueue(image:, **infer_opts)
-> { call(image, **infer_opts) }
end
def process_outputs(outputs, **)
outputs
end
def call_v2(image, offset_x, offset_y, split_page, confidence:, resolution:) def call_v2(image, offset_x, offset_y, split_page, confidence:, resolution:)
if split_page && image.height > image.width if split_page && image.height > image.width
regions = build_split_image_regions(image) regions = build_split_image_regions(image)

@ -613,7 +613,7 @@ RSpec.describe 'Signing Form' do
visit submit_form_path(slug: submitter.slug) visit submit_form_path(slug: submitter.slug)
find('#expand_form_button').click find('#expand_form_button').click
click_link 'Type' click_button 'Type'
fill_in 'signature_text_input', with: 'John Doe' fill_in 'signature_text_input', with: 'John Doe'
click_button 'Sign and Complete' click_button 'Sign and Complete'
@ -752,7 +752,7 @@ RSpec.describe 'Signing Form' do
visit submit_form_path(slug: submitter.slug) visit submit_form_path(slug: submitter.slug)
find('#expand_form_button').click find('#expand_form_button').click
click_link 'Draw' click_button 'Draw'
draw_canvas draw_canvas
click_button 'Complete' click_button 'Complete'
@ -1169,7 +1169,7 @@ RSpec.describe 'Signing Form' do
find('#decline_button').click find('#decline_button').click
fill_in 'reason', with: 'I do not agree with the terms' fill_in 'reason', with: 'I do not agree with the terms'
click_button 'Decline' within('dialog[open]') { click_button 'Decline' }
expect(page).to have_content('Form has been declined') expect(page).to have_content('Form has been declined')
@ -1193,7 +1193,7 @@ RSpec.describe 'Signing Form' do
find('#delegate_button').click find('#delegate_button').click
fill_in 'email', with: 'delegate@example.com' fill_in 'email', with: 'delegate@example.com'
click_button 'Delegate' within('dialog[open]') { click_button 'Delegate' }
expect(page).to have_content('Document has been delegated') expect(page).to have_content('Document has been delegated')

Loading…
Cancel
Save