process payment fields

pull/150/merge
Pete Matsyburka 2 years ago
parent af4cf7257f
commit 0e9343d9e2

@ -21,10 +21,6 @@ gem 'jwt'
gem 'lograge' gem 'lograge'
gem 'mysql2', require: false gem 'mysql2', require: false
gem 'oj' gem 'oj'
gem 'omniauth-github'
gem 'omniauth-google-oauth2'
gem 'omniauth-microsoft-office365'
gem 'omniauth-rails_csrf_protection'
gem 'pagy' gem 'pagy'
gem 'pdf-reader' gem 'pdf-reader'
gem 'pg', require: false gem 'pg', require: false

@ -245,7 +245,6 @@ GEM
signet (>= 0.16, < 2.a) signet (>= 0.16, < 2.a)
hashdiff (1.0.1) hashdiff (1.0.1)
hashery (2.1.2) hashery (2.1.2)
hashie (5.0.0)
hexapdf (0.34.1) hexapdf (0.34.1)
cmdparse (~> 3.0, >= 3.0.3) cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1) geom2d (~> 0.4, >= 0.4.1)
@ -296,7 +295,6 @@ GEM
minitest (5.20.0) minitest (5.20.0)
msgpack (1.7.2) msgpack (1.7.2)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0) multipart-post (2.3.0)
mysql2 (0.5.5) mysql2 (0.5.5)
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
@ -316,35 +314,7 @@ GEM
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin) nokogiri (1.15.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
oj (3.16.0) oj (3.16.0)
omniauth (2.1.1)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-github (2.0.1)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
omniauth-google-oauth2 (1.1.1)
jwt (>= 2.0)
oauth2 (~> 2.0.6)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0)
omniauth-microsoft-office365 (0.0.8)
omniauth
omniauth-oauth2
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.2.0) openssl (3.2.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
@ -378,8 +348,6 @@ GEM
nio4r (~> 2.0) nio4r (~> 2.0)
racc (1.7.1) racc (1.7.1)
rack (2.2.8) rack (2.2.8)
rack-protection (3.1.0)
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.6) rack-proxy (0.7.6)
rack rack
rack-test (2.1.0) rack-test (2.1.0)
@ -520,9 +488,6 @@ GEM
simplecov-html (0.12.3) simplecov-html (0.12.3)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
smart_properties (1.17.0) smart_properties (1.17.0)
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
sqlite3 (1.6.3) sqlite3 (1.6.3)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
sqlite3 (1.6.3-arm64-darwin) sqlite3 (1.6.3-arm64-darwin)
@ -543,7 +508,6 @@ GEM
uber (0.1.0) uber (0.1.0)
unicode-display_width (2.5.0) unicode-display_width (2.5.0)
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
version_gem (1.1.3)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.0) web-console (4.2.0)
@ -595,10 +559,6 @@ DEPENDENCIES
lograge lograge
mysql2 mysql2
oj oj
omniauth-github
omniauth-google-oauth2
omniauth-microsoft-office365
omniauth-rails_csrf_protection
pagy pagy
pdf-reader pdf-reader
pg pg

@ -58,7 +58,9 @@ module Api
schema: [%i[attachment_uuid name]], schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]], submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value, fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
{ options: [%i[value uuid]], areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] { preferences: {},
options: [%i[value uuid]],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
) )
end end
end end

@ -1,16 +0,0 @@
# frozen_string_literal: true
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
def google_oauth2
@user = Users.from_omniauth(request.env['omniauth.auth'])
if @user.persisted?
flash[:notice] = I18n.t('devise.omniauth_callbacks.success', kind: 'Google')
sign_in_and_redirect @user, event: :authentication
else
redirect_to new_registration_path(oauth_callback: true, user: @user.slice(:email, :first_name, :last_name)),
notice: 'Please complete registration with Google auth'
end
end
end

@ -86,6 +86,7 @@ window.customElements.define('template-builder', class extends HTMLElement {
backgroundColor: '#faf7f5', backgroundColor: '#faf7f5',
withPhone: this.dataset.withPhone === 'true', withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false', withLogo: this.dataset.withLogo !== 'false',
withPayment: this.dataset.withPayment !== 'false',
acceptFileTypes: this.dataset.acceptFileTypes, acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true' isDirectUpload: this.dataset.isDirectUpload === 'true'
}) })

@ -10,7 +10,6 @@ window.customElements.define('submission-form', class extends HTMLElement {
this.app = createApp(Form, { this.app = createApp(Form, {
submitter: JSON.parse(this.dataset.submitter), submitter: JSON.parse(this.dataset.submitter),
authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true', canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true', isDirectUpload: this.dataset.isDirectUpload === 'true',
goToLast: this.dataset.goToLast === 'true', goToLast: this.dataset.goToLast === 'true',

@ -53,7 +53,7 @@
:src="initials.url" :src="initials.url"
> >
<div <div
v-else-if="field.type === 'file'" v-else-if="field.type === 'file' || field.type === 'payment'"
class="px-0.5 flex flex-col justify-center" class="px-0.5 flex flex-col justify-center"
> >
<a <a
@ -158,7 +158,7 @@
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard } from '@tabler/icons-vue'
export default { export default {
name: 'FieldArea', name: 'FieldArea',
@ -231,7 +231,8 @@ export default {
checkbox: 'Checkbox', checkbox: 'Checkbox',
radio: 'Radio', radio: 'Radio',
multiple: 'Multiple Select', multiple: 'Multiple Select',
phone: 'Phone' phone: 'Phone',
payment: 'Payment'
} }
}, },
fieldIcons () { fieldIcons () {
@ -247,7 +248,8 @@ export default {
radio: IconCircleDot, radio: IconCircleDot,
cells: IconColumns3, cells: IconColumns3,
multiple: IconChecks, multiple: IconChecks,
phone: IconPhoneCheck phone: IconPhoneCheck,
payment: IconCreditCard
} }
}, },
image () { image () {
@ -281,6 +283,8 @@ export default {
attachments () { attachments () {
if (this.field.type === 'file') { if (this.field.type === 'file') {
return (this.modelValue || []).map((uuid) => this.attachmentsIndex[uuid]) return (this.modelValue || []).map((uuid) => this.attachmentsIndex[uuid])
} else if (this.field.type === 'payment') {
return [this.attachmentsIndex[this.modelValue]].filter(Boolean)
} else { } else {
return [] return []
} }

@ -266,8 +266,22 @@
@focus="$refs.areas.scrollIntoField(currentField)" @focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep" @submit="submitStep"
/> />
<PaymentStep
v-else-if="currentField.type === 'payment'"
ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
@focus="$refs.areas.scrollIntoField(currentField)"
@submit="submitStep"
/>
</div> </div>
<div class="mt-6 md:mt-8"> <div
v-if="currentField.type !== 'payment' || submittedValues[currentField.uuid]"
class="mt-6 md:mt-8"
>
<button <button
ref="submitButton" ref="submitButton"
type="submit" type="submit"
@ -331,6 +345,7 @@ import InitialsStep from './initials_step'
import AttachmentStep from './attachment_step' import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step' import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step' import PhoneStep from './phone_step'
import PaymentStep from './payment_step'
import TextStep from './text_step' import TextStep from './text_step'
import DateStep from './date_step' import DateStep from './date_step'
import FormCompleted from './completed' import FormCompleted from './completed'
@ -351,6 +366,7 @@ export default {
IconArrowsDiagonal, IconArrowsDiagonal,
TextStep, TextStep,
PhoneStep, PhoneStep,
PaymentStep,
IconArrowsDiagonalMinimize2, IconArrowsDiagonalMinimize2,
FormCompleted FormCompleted
}, },
@ -402,11 +418,6 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
authenticityToken: {
type: String,
required: false,
default: ''
},
backgroundColor: { backgroundColor: {
type: String, type: String,
required: false, required: false,
@ -463,6 +474,12 @@ export default {
currentStepFields () { currentStepFields () {
return this.stepFields[this.currentStep] return this.stepFields[this.currentStep]
}, },
queryParams () {
return new URLSearchParams(window.location.search)
},
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
submitterSlug () { submitterSlug () {
return this.submitter.slug return this.submitter.slug
}, },
@ -525,7 +542,13 @@ export default {
} }
}) })
if (this.goToLast) { if (this.queryParams.get('field_uuid')) {
const stepIndex = this.stepFields.findIndex((fields) => {
return fields.some((f) => f.uuid === this.queryParams.get('field_uuid'))
})
this.currentStep = Math.max(stepIndex, 0)
} else if (this.goToLast) {
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid]))) const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid])))
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1 const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
@ -570,7 +593,7 @@ export default {
methods: { methods: {
t, t,
maybeTrackEmailClick () { maybeTrackEmailClick () {
const queryParams = new URLSearchParams(window.location.search) const { queryParams } = this
if (queryParams.has('t')) { if (queryParams.has('t')) {
const t = queryParams.get('t') const t = queryParams.get('t')
@ -594,7 +617,7 @@ export default {
} }
}, },
maybeTrackSmsClick () { maybeTrackSmsClick () {
const queryParams = new URLSearchParams(window.location.search) const { queryParams } = this
if (queryParams.has('c')) { if (queryParams.has('c')) {
const c = queryParams.get('c') const c = queryParams.get('c')
@ -667,7 +690,7 @@ export default {
async submitStep () { async submitStep () {
this.isSubmitting = true this.isSubmitting = true
const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type) const stepPromise = ['signature', 'phone', 'initials', 'payment'].includes(this.currentField.type)
? this.$refs.currentStep.submit ? this.$refs.currentStep.submit
: () => Promise.resolve({}) : () => Promise.resolve({})
@ -684,7 +707,7 @@ export default {
} }
await this.saveStep(formData).then(async (response) => { await this.saveStep(formData).then(async (response) => {
if (response.status === 422) { if (response.status === 422 || response.status === 500) {
const data = await response.json() const data = await response.json()
alert(data.error || 'Value is invalid') alert(data.error || 'Value is invalid')
@ -714,7 +737,7 @@ export default {
} }
} }
}).catch(error => { }).catch(error => {
console.error('Error submitting form:', error) alert(error)
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
}) })

@ -38,7 +38,9 @@ const en = {
type: 'Type', type: 'Type',
type_text: 'Type text', type_text: 'Type text',
date: 'Date', date: 'Date',
email_has_been_sent: 'Email has been sent' email_has_been_sent: 'Email has been sent',
processing: 'Processing',
pay_with_strip: 'Pay with Stripe'
} }
const es = { const es = {
@ -81,7 +83,9 @@ const es = {
draw: 'Dibujar', draw: 'Dibujar',
type: 'Escribir', type: 'Escribir',
type_text: 'Escribir texto', type_text: 'Escribir texto',
email_has_been_sent: 'El correo electrónico ha sido enviado' email_has_been_sent: 'El correo electrónico ha sido enviado',
processing: 'Procesando',
pay_with_strip: 'Pagar con Stripe'
} }
const it = { const it = {
@ -124,7 +128,9 @@ const it = {
type: 'Inserisci', type: 'Inserisci',
type_text: 'Inserisci testo', type_text: 'Inserisci testo',
toggle_multiline_text: 'Attiva Testo Multilinea', toggle_multiline_text: 'Attiva Testo Multilinea',
email_has_been_sent: "L'email è stata inviata" email_has_been_sent: "L'email è stata inviata",
processing: 'Elaborazione',
pay_with_strip: 'Paga con Stripe'
} }
const de = { const de = {
@ -167,7 +173,9 @@ const de = {
type: 'Eingeben', type: 'Eingeben',
type_text: 'Text eingeben', type_text: 'Text eingeben',
toggle_multiline_text: 'Mehrzeiligen Text umschalten', toggle_multiline_text: 'Mehrzeiligen Text umschalten',
email_has_been_sent: 'Die E-Mail wurde gesendet' email_has_been_sent: 'Die E-Mail wurde gesendet',
processing: 'Verarbeitung',
pay_with_strip: 'Mit Stripe bezahlen'
} }
const fr = { const fr = {
@ -210,7 +218,9 @@ const fr = {
type: 'Saisir', type: 'Saisir',
type_text: 'Saisir du texte', type_text: 'Saisir du texte',
toggle_multiline_text: 'Basculer le Texte Multiligne', toggle_multiline_text: 'Basculer le Texte Multiligne',
email_has_been_sent: "L'email a été envoyé" email_has_been_sent: "L'email a été envoyé",
processing: 'Traitement',
pay_with_strip: 'Paiement avec Stripe'
} }
const pl = { const pl = {
@ -253,7 +263,9 @@ const pl = {
type: 'Wprowadź', type: 'Wprowadź',
type_text: 'Wprowadź tekst', type_text: 'Wprowadź tekst',
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy', toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany' email_has_been_sent: 'E-mail został wysłany',
processing: 'Przetwarzanie',
pay_with_strip: 'Płatność za pomocą Stripe'
} }
const uk = { const uk = {
@ -296,7 +308,9 @@ const uk = {
type: 'Текст', type: 'Текст',
type_text: 'Введіть текст', type_text: 'Введіть текст',
toggle_multiline_text: 'Перемкнути Багаторядковий Текст', toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений' email_has_been_sent: 'Електронний лист був відправлений',
processing: 'Обробка',
pay_with_strip: 'Сплатити за допомогою Stripe'
} }
const cs = { const cs = {
@ -339,7 +353,9 @@ const cs = {
type: 'Zadat', type: 'Zadat',
type_text: 'Zadat text', type_text: 'Zadat text',
toggle_multiline_text: 'Přepnout Víceřádkový Text', toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán' email_has_been_sent: 'E-mail byl odeslán',
processing: 'Zpracování',
pay_with_strip: 'Zaplacení přes Stripe'
} }
const pt = { const pt = {
@ -382,7 +398,9 @@ const pt = {
type: 'Inserir', type: 'Inserir',
type_text: 'Inserir texto', type_text: 'Inserir texto',
toggle_multiline_text: 'Alternar Texto Multilinha', toggle_multiline_text: 'Alternar Texto Multilinha',
email_has_been_sent: 'Email enviado' email_has_been_sent: 'Email enviado',
processing: 'Processamento',
pay_with_strip: 'Pagar com Stripe'
} }
const i18n = { en, es, it, de, fr, pl, uk, cs, pt } const i18n = { en, es, it, de, fr, pl, uk, cs, pt }

@ -0,0 +1,181 @@
<template>
<label
v-if="!modelValue && !sessionId"
:for="field.uuid"
class="label text-2xl mb-2"
>{{ field.name || defaultName }}
</label>
<div>
<input
type="text"
:value="modelValue"
hidden
:name="`values[${field.uuid}]`"
class="hidden"
>
<div
v-if="modelValue && !sessionId"
class=" text-2xl mb-2"
>
Already paid
</div>
<div v-else>
<button
v-if="sessionId"
disabled
class="base-button w-full"
>
<IconLoader
width="22"
class="animate-spin"
/>
<span>
{{ t('processing') }}...
</span>
</button>
<button
v-else
:id="field.uuid"
class="btn bg-[#7B73FF] text-white hover:bg-[#0A2540] text-lg w-full"
:class="{ disabled: isCreatingCheckout }"
:disabled="isCreatingCheckout"
@click.prevent="startCheckout"
>
<IconInnerShadowTop
v-if="isCreatingCheckout"
width="22"
class="animate-spin"
/>
<IconBrandStripe
v-else
width="22"
/>
<span>
{{ t('pay_with_strip') }}
</span>
</button>
</div>
</div>
</template>
<script>
import { IconBrandStripe, IconInnerShadowTop, IconLoader } from '@tabler/icons-vue'
export default {
name: 'PaymentStep',
components: {
IconBrandStripe,
IconInnerShadowTop,
IconLoader
},
inject: ['baseUrl', 't'],
props: {
modelValue: {
type: String,
required: false,
default: ''
},
field: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
}
},
emits: ['focus', 'submit', 'update:model-value', 'attached'],
data () {
return {
isCreatingCheckout: false
}
},
computed: {
queryParams () {
return new URLSearchParams(window.location.search)
},
sessionId () {
return this.queryParams.get('stripe_session_id')
},
defaultName () {
const { price, currency } = this.field.preferences || {}
const formattedPrice = new Intl.NumberFormat([], {
style: 'currency',
currency
}).format(price)
return `Pay ${formattedPrice}`
}
},
mounted () {
if (this.sessionId) {
this.$emit('submit')
}
},
methods: {
async submit () {
if (this.sessionId) {
return fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments/' + this.sessionId), {
method: 'PUT',
body: JSON.stringify({
submitter_slug: this.submitterSlug
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
alert(data.error || 'Unexpected error')
return Promise.reject(new Error(data.error))
}
const attachment = await resp.json()
window.history.replaceState({}, document.title, window.location.pathname)
this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
return resp
})
} else {
return Promise.resolve({})
}
},
startCheckout () {
this.isCreatingCheckout = true
fetch((this.baseUrl || '/embed').replace('/embed', '/api/stripe_payments'), {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
field_uuid: this.field.uuid
}),
headers: { 'Content-Type': 'application/json' }
}).then(async (resp) => {
if (resp.status === 422 || resp.status === 500) {
const data = await resp.json()
alert(data.message || 'Unexpected error')
return Promise.reject(new Error(data.message))
}
const { url } = await resp.json()
const link = document.createElement('a')
link.href = url
if (url) {
link.click()
}
}).finally(() => {
this.isCreatingCheckout = false
})
}
}
}
</script>

@ -301,6 +301,7 @@ export default {
baseFetch: this.baseFetch, baseFetch: this.baseFetch,
backgroundColor: this.backgroundColor, backgroundColor: this.backgroundColor,
withPhone: this.withPhone, withPhone: this.withPhone,
withPayment: this.withPayment,
selectedAreaRef: computed(() => this.selectedAreaRef) selectedAreaRef: computed(() => this.selectedAreaRef)
} }
}, },
@ -369,6 +370,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
withPayment: {
type: Boolean,
required: false,
default: false
},
fetchOptions: { fetchOptions: {
type: Object, type: Object,
required: false, required: false,
@ -436,6 +442,12 @@ export default {
window.addEventListener('keydown', this.onKeyDown) window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize) window.addEventListener('resize', this.onWindowResize)
this.$nextTick(() => {
if (document.location.search?.includes('stripe_connect_success')) {
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
}
})
}, },
unmounted () { unmounted () {
document.removeEventListener('keyup', this.onKeyUp) document.removeEventListener('keyup', this.onKeyUp)

@ -65,8 +65,12 @@
:stroke-width="1.6" :stroke-width="1.6"
/> />
</button> </button>
<PaymentSettings
v-if="field.type === 'payment'"
:field="field"
/>
<span <span
v-if="!defaultField" v-else-if="!defaultField"
class="dropdown dropdown-end" class="dropdown dropdown-end"
> >
<label <label
@ -261,6 +265,7 @@
<script> <script>
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import FieldType from './field_type' import FieldType from './field_type'
import PaymentSettings from './payment_settings'
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue' import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
@ -270,6 +275,7 @@ export default {
Contenteditable, Contenteditable,
IconSettings, IconSettings,
IconShape, IconShape,
PaymentSettings,
IconNewSection, IconNewSection,
IconTrashX, IconTrashX,
IconCopy, IconCopy,
@ -295,17 +301,29 @@ export default {
emits: ['set-draw', 'remove', 'scroll-to'], emits: ['set-draw', 'remove', 'scroll-to'],
data () { data () {
return { return {
isNameFocus: false isNameFocus: false,
showPaymentModal: false
} }
}, },
computed: { computed: {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
defaultName () { defaultName () {
if (this.field.type === 'payment' && this.field.preferences?.price) {
const { price, currency } = this.field.preferences || {}
const formattedPrice = new Intl.NumberFormat([], {
style: 'currency',
currency
}).format(price)
return `${this.fieldNames[this.field.type]} ${formattedPrice}`
} else {
const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field) const typeIndex = this.template.fields.filter((f) => f.type === this.field.type).indexOf(this.field)
const suffix = { multiple: 'Select', radio: 'Group' }[this.field.type] || 'Field' const suffix = { multiple: 'Select', radio: 'Group' }[this.field.type] || 'Field'
return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}` return `${this.fieldNames[this.field.type]} ${suffix} ${typeIndex + 1}`
}
}, },
areas () { areas () {
return this.field.areas || [] return this.field.areas || []

@ -25,7 +25,7 @@
v-for="(icon, type) in fieldIcons" v-for="(icon, type) in fieldIcons"
:key="type" :key="type"
> >
<li v-if="withPhone || type !== 'phone'"> <li v-if="withPhone || withPayment || !['phone', 'payment'].includes(type)">
<a <a
href="#" href="#"
class="text-sm py-1 px-2" class="text-sm py-1 px-2"
@ -46,11 +46,11 @@
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper, IconCreditCard } from '@tabler/icons-vue'
export default { export default {
name: 'FiledTypeDropdown', name: 'FiledTypeDropdown',
inject: ['withPhone'], inject: ['withPhone', 'withPayment'],
props: { props: {
modelValue: { modelValue: {
type: String, type: String,
@ -92,7 +92,8 @@ export default {
multiple: 'Multiple', multiple: 'Multiple',
radio: 'Radio', radio: 'Radio',
cells: 'Cells', cells: 'Cells',
phone: 'Phone' phone: 'Phone',
payment: 'Payment'
} }
}, },
fieldIcons () { fieldIcons () {
@ -108,7 +109,8 @@ export default {
radio: IconCircleDot, radio: IconCircleDot,
multiple: IconChecks, multiple: IconChecks,
cells: IconColumns3, cells: IconColumns3,
phone: IconPhoneCheck phone: IconPhoneCheck,
payment: IconCreditCard
} }
} }
}, },

@ -71,7 +71,7 @@
:key="type" :key="type"
> >
<button <button
v-if="withPhone || type != 'phone'" v-if="(withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true" draggable="true"
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative" class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }" :style="{ backgroundColor: backgroundColor }"
@ -90,7 +90,7 @@
</div> </div>
</button> </button>
<div <div
v-else v-else-if="type == 'phone'"
class="tooltip tooltip-bottom-end flex" class="tooltip tooltip-bottom-end flex"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification." data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
> >
@ -152,7 +152,7 @@ export default {
IconDrag, IconDrag,
IconLock IconLock
}, },
inject: ['save', 'backgroundColor', 'withPhone'], inject: ['save', 'backgroundColor', 'withPhone', 'withPayment'],
props: { props: {
fields: { fields: {
type: Array, type: Array,

@ -0,0 +1,237 @@
<template>
<span
class="dropdown dropdown-end"
:class="{ 'dropdown-open': (!field.preferences?.price || !isConnected) && !isLoading }"
>
<label
tabindex="0"
title="Settings"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconSettings
:width="18"
:stroke-width="1.6"
/>
</label>
<ul
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow bg-base-100 rounded-box w-52 z-10"
draggable="true"
@dragstart.prevent.stop
@click="closeDropdown"
>
<div
class="py-1.5 px-1 relative"
@click.stop
>
<select
v-model="field.preferences.currency"
placeholder="Price"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0"
@change="save"
>
<option
v-for="currency in currencies"
:key="currency"
:value="currency"
>
{{ currency }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
Currency
</label>
</div>
<div
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.preferences.price"
type="number"
placeholder="Price"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save"
>
<label
v-if="field.preferences.price"
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
Price
</label>
</div>
<div
v-if="!isConnected || isOauthSuccess"
class="py-1.5 px-1 relative"
@click.stop
>
<div
v-if="isConnected && isOauthSuccess"
class="text-sm text-center"
>
<IconCircleCheck
class="inline text-green-600 w-4 h-4"
/>
Stripe Connected
</div>
<form
v-if="!isConnected"
data-turbo="false"
action="/auth/stripe_connect"
accept-charset="UTF-8"
target="_blank"
method="post"
>
<input
type="hidden"
name="state"
:value="oauthState"
autocomplete="off"
>
<input
type="hidden"
name="redirect_uri"
:value="redirectUri"
autocomplete="off"
>
<input
type="hidden"
name="scope"
value="read_write"
autocomplete="off"
>
<input
type="hidden"
name="authenticity_token"
:value="authenticityToken"
autocomplete="off"
>
<button
type="submit"
:disabled="isLoading"
class="btn bg-[#7B73FF] hover:bg-[#0A2540] btn-sm text-white w-full"
>
<span
v-if="isLoading"
class="flex items-center space-x-1"
>
<IconInnerShadowTop
class="w-4 h-4 animate-spin inline"
/>
<span>
Connect Stripe
</span>
</span>
<span
v-else
class="flex items-center space-x-1"
>
<IconBrandStripe
class="w-4 h-4 inline"
/>
<span>
Connect Stripe
</span>
</span>
</button>
</form>
</div>
</ul>
</span>
</template>
<script>
import { IconSettings, IconCircleCheck, IconBrandStripe, IconInnerShadowTop } from '@tabler/icons-vue'
import { ref } from 'vue'
const isConnected = ref(false)
export default {
name: 'PaymentSettings',
components: {
IconSettings,
IconCircleCheck,
IconInnerShadowTop,
IconBrandStripe
},
inject: ['backgroundColor', 'save'],
props: {
field: {
type: Object,
required: true
}
},
data () {
return {
isLoading: false
}
},
computed: {
isConnected: () => isConnected.value,
isOauthSuccess () {
return document.location.search?.includes('stripe_connect_success')
},
redirectUri () {
return document.location.origin + '/auth/stripe_connect/callback'
},
currencies () {
return ['USD', 'EUR', 'GBP']
},
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
oauthState () {
const params = new URLSearchParams('')
params.set('redir', document.location.href)
return params.toString()
},
defaultCurrency () {
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (userTimezone.startsWith('Europe')) {
return 'EUR'
} else if (userTimezone.includes('London') || userTimezone.includes('Belfast')) {
return 'GBP'
} else {
return 'USD'
}
}
},
created () {
this.field.preferences ||= {}
},
mounted () {
this.field.preferences.currency ||= this.defaultCurrency
if (!this.isConnected) {
this.checkStatus()
}
},
methods: {
checkStatus () {
this.isLoading = true
fetch('/api/stripe_connect').then(async (resp) => {
const { status } = await resp.json()
if (status === 'connected') {
isConnected.value = true
}
}).finally(() => {
this.isLoading = false
})
},
closeDropdown () {
document.activeElement.blur()
}
}
}
</script>

@ -100,7 +100,7 @@ class SubmitterMailer < ApplicationMailer
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
file_fields = submitter.submission.template_fields.select { |e| e['type'] == 'file' } file_fields = submitter.submission.template_fields.select { |e| e['type'].in?(%w[file payment]) }
if file_fields.pluck('submitter_uuid').uniq.size == 1 if file_fields.pluck('submitter_uuid').uniq.size == 1
storage_attachments = storage_attachments =

@ -59,10 +59,6 @@ class User < ApplicationRecord
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
if Docuseal.multitenant?
devise :registerable, :omniauthable, omniauth_providers: %i[google_oauth2 microsoft_office365 github]
end
attribute :role, :string, default: ADMIN_ROLE attribute :role, :string, default: ADMIN_ROLE
attribute :uuid, :string, default: -> { SecureRandom.uuid } attribute :uuid, :string, default: -> { SecureRandom.uuid }

@ -1,7 +1,7 @@
<field-value class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%"> <field-value class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<% if field['type'].in?(['signature', 'image', 'initials']) %> <% if field['type'].in?(['signature', 'image', 'initials']) %>
<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">
<% elsif field['type'] == 'file' %> <% elsif field['type'].in?(['file', 'payment']) %>
<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">
<% Array.wrap(value).each do |val| %> <% Array.wrap(value).each do |val| %>

@ -148,7 +148,7 @@
</div> </div>
<% elsif field['type'] == 'image' %> <% elsif field['type'] == 'image' %>
<img class="object-contain mx-auto max-h-28" 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" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'] == 'file' %> <% elsif field['type'] == 'file' || field['type'] == 'payment' %>
<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| %>
<a target="_blank" class="flex items-center space-x-1" href="<%= attachments_index[val].url %>"> <a target="_blank" class="flex items-center space-x-1" href="<%= attachments_index[val].url %>">

@ -1,4 +1,4 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %> <% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %> <% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<% configs = Submitters::FormConfigs.call(submitter) %> <% configs = Submitters::FormConfigs.call(submitter) %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form> <submission-form data-is-demo="<%= Docuseal.demo? %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>

@ -14,7 +14,6 @@ Devise.otp_allowed_drift = 60.seconds
# #
# Use this hook to configure devise mailer, warden hooks and so forth. # Use this hook to configure devise mailer, warden hooks and so forth.
# Many of these configuration options can be set straight in your model. # Many of these configuration options can be set straight in your model.
# rubocop:disable Metrics/BlockLength
Devise.setup do |config| Devise.setup do |config|
config.warden do |manager| config.warden do |manager|
manager.default_strategies(scope: :user).unshift(:two_factor_authenticatable) manager.default_strategies(scope: :user).unshift(:two_factor_authenticatable)
@ -274,17 +273,6 @@ Devise.setup do |config|
# The default HTTP method used to sign out a resource. Default is :delete. # The default HTTP method used to sign out a resource. Default is :delete.
config.sign_out_via = :delete config.sign_out_via = :delete
# ==> OmniAuth
# Add a new OmniAuth provider. Check the wiki for more information on setting
# up on your models and hooks.
config.omniauth :google_oauth2, ENV.fetch('GOOGLE_CLIENT_ID', nil), ENV.fetch('GOOGLE_CLIENT_SECRET', nil), {}
config.omniauth :microsoft_office365, ENV.fetch('OFFICE365_CLIENT_ID', nil),
ENV.fetch('OFFICE365_CLIENT_SECRET', nil), {}
if ENV['GITHUB_CLIENT_ID']
config.omniauth :github, ENV.fetch('GITHUB_CLIENT_ID', nil), ENV.fetch('GITHUB_CLIENT_SECRET', nil), {}
end
# ==> Warden configuration # ==> Warden configuration
# If you want to use other strategies, that are not supported by Devise, or # If you want to use other strategies, that are not supported by Devise, or
# change the failure app, you can configure them inside the config.warden block. # change the failure app, you can configure them inside the config.warden block.
@ -322,5 +310,6 @@ Devise.setup do |config|
# When set to false, does not sign a user in automatically after their password is # When set to false, does not sign a user in automatically after their password is
# changed. Defaults to true, so a user is signed in automatically after changing a password. # changed. Defaults to true, so a user is signed in automatically after changing a password.
# config.sign_in_after_change_password = true # config.sign_in_after_change_password = true
ActiveSupport.run_load_hooks(:devise_config, config)
end end
# rubocop:enable Metrics/BlockLength

@ -10,7 +10,7 @@ Rails.application.routes.draw do
path: '/', only: %i[sessions passwords omniauth_callbacks], path: '/', only: %i[sessions passwords omniauth_callbacks],
controllers: begin controllers: begin
options = { sessions: 'sessions', passwords: 'passwords' } options = { sessions: 'sessions', passwords: 'passwords' }
options[:omniauth_callbacks] = 'omniauth_callbacks' if Docuseal.multitenant? options[:omniauth_callbacks] = 'omniauth_callbacks' if User.devise_modules.include?(:omniauthable)
options options
end end

@ -22,6 +22,12 @@ module Submissions
VERIFIED_TEXT = 'Verified' VERIFIED_TEXT = 'Verified'
UNVERIFIED_TEXT = 'Unverified' UNVERIFIED_TEXT = 'Unverified'
CURRENCY_SYMBOLS = {
'USD' => '$',
'EUR' => '€',
'GBP' => '£'
}.freeze
module_function module_function
# rubocop:disable Metrics # rubocop:disable Metrics
@ -200,7 +206,15 @@ module Submissions
composer.image(io, width:, height:, margin: [0, 0, 10, 0]) composer.image(io, width:, height:, margin: [0, 0, 10, 0])
composer.formatted_text_box([{ text: '' }]) composer.formatted_text_box([{ text: '' }])
elsif field['type'] == 'file' elsif field['type'].in?(%w[file payment])
if field['type'] == 'payment'
unit = CURRENCY_SYMBOLS[field['preferences']['currency']]
price = ApplicationController.helpers.number_to_currency(field['preferences']['price'], unit:)
composer.formatted_text_box([{ text: "Paid #{price}\n" }], padding: [0, 0, 10, 0])
end
composer.formatted_text_box( composer.formatted_text_box(
Array.wrap(value).map do |uuid| Array.wrap(value).map do |uuid|
attachment = submitter.attachments.find { |a| a.uuid == uuid } attachment = submitter.attachments.find { |a| a.uuid == uuid }

@ -85,7 +85,7 @@ module Submissions
width: image.width * scale, width: image.width * scale,
height: image.height * scale height: image.height * scale
) )
when 'file' when 'file', 'payment'
items = Array.wrap(value).each_with_object([]) do |uuid, acc| items = Array.wrap(value).each_with_object([]) do |uuid, acc|
attachment = submitter.attachments.find { |a| a.uuid == uuid } attachment = submitter.attachments.find { |a| a.uuid == uuid }

@ -99,7 +99,7 @@ module Submitters
def replace_default_variables(value, attrs, template, with_time: false) def replace_default_variables(value, attrs, template, with_time: false)
return if value.blank? return if value.blank?
value.gsub(VARIABLE_REGEXP) do |e| value.to_s.gsub(VARIABLE_REGEXP) do |e|
case key = ::Regexp.last_match(1) case key = ::Regexp.last_match(1)
when 'time' when 'time'
if with_time if with_time

Loading…
Cancel
Save