feat: tax form download stage (#4513)

* feat: start on fix

* fix: withdraw btn

* fix: lint issues

* feat: start on download stage for tax form modal

* fix: use button rather than span

* fix: lint

* fix: lint issues

* feat: tax form notification email for users who didnt get chance to download

* feat: finish download stage for tax modal

* fix: lint & i18n

* fix: lint + svg cleanup

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: --global <--global>
This commit is contained in:
Calum H.
2025-10-07 22:51:43 +01:00
committed by GitHub
parent ad705fa66f
commit f32558cf97
9 changed files with 301 additions and 107 deletions

View File

@@ -1,89 +1,161 @@
<template>
<NewModal ref="taxFormModal" :header="formatMessage(messages.taxFormHeader)">
<div class="w-full sm:w-[540px]">
<Admonition type="info" :header="formatMessage(messages.securityHeader)">
<IntlFormatted :message-id="messages.securityDescription">
<template #security-link="{ children }">
<a
href="https://www.track1099.com/info/security"
class="flex w-fit flex-row gap-1 align-middle text-link"
target="_blank"
rel="noopener noreferrer"
>
<component :is="() => normalizeChildren(children)" />
<ExternalIcon class="my-auto" />
</a>
</template>
</IntlFormatted>
</Admonition>
<div class="mt-4 flex flex-col gap-2">
<label>
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.usCitizenQuestion) }}
<span class="text-brand-red">*</span>
</span>
</label>
<Chips
v-model="isUSCitizen"
:items="['yes', 'no']"
:format-label="
(item) => (item === 'yes' ? formatMessage(messages.yes) : formatMessage(messages.no))
"
:never-empty="false"
:capitalize="true"
/>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-in-out"
enter-from-class="h-0 overflow-hidden opacity-0"
enter-to-class="h-auto overflow-visible opacity-100"
leave-active-class="transition-all duration-300 ease-in-out"
leave-from-class="h-auto overflow-visible opacity-100"
leave-to-class="h-0 overflow-hidden opacity-0"
>
<div v-if="isUSCitizen === 'no'" class="flex flex-col gap-1">
<label class="mt-4">
<NewModal
ref="taxFormModal"
:header="formatMessage(messages.taxFormHeader)"
:hide-header="currentStage === 'download-confirmation'"
:close-on-click-outside="currentStage !== 'download-confirmation'"
:close-on-esc="currentStage !== 'download-confirmation'"
>
<div
class="w-full"
:class="[currentStage === 'form-selection' ? 'sm:w-[540px]' : 'sm:w-[400px]']"
>
<div v-if="currentStage === 'form-selection'">
<Admonition type="info" :header="formatMessage(messages.securityHeader)">
<IntlFormatted :message-id="messages.securityDescription">
<template #security-link="{ children }">
<a
href="https://www.track1099.com/info/security"
class="flex w-fit flex-row gap-1 align-middle text-link"
target="_blank"
rel="noopener noreferrer"
>
<component :is="() => normalizeChildren(children)" />
<ExternalIcon class="my-auto" />
</a>
</template>
</IntlFormatted>
</Admonition>
<div class="mt-4 flex flex-col gap-2">
<label>
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.entityQuestion) }}
{{ formatMessage(messages.usCitizenQuestion) }}
<span class="text-brand-red">*</span>
</span>
</label>
<Chips
v-model="entityType"
:items="['private-individual', 'foreign-entity']"
v-model="isUSCitizen"
:items="['yes', 'no']"
:format-label="
(item) =>
item === 'private-individual'
? formatMessage(messages.privateIndividual)
: formatMessage(messages.foreignEntity)
(item) => (item === 'yes' ? formatMessage(messages.yes) : formatMessage(messages.no))
"
:never-empty="false"
:capitalize="false"
class="mt-2"
:capitalize="true"
/>
<span class="text-md mt-2 leading-tight">
{{ formatMessage(messages.entityDescription) }}
</span>
</div>
</Transition>
<div class="mt-4 flex justify-end gap-3">
<ButtonStyled @click="handleCancel">
<button><XIcon /> {{ formatMessage(messages.cancel) }}</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="!canContinue || loading" @click="continueForm">
{{ formatMessage(messages.continue) }}
<RightArrowIcon v-if="!loading" /> <SpinnerIcon v-else class="animate-spin" />
</button>
</ButtonStyled>
<Transition
enter-active-class="transition-all duration-300 ease-in-out"
enter-from-class="h-0 overflow-hidden opacity-0"
enter-to-class="h-auto overflow-visible opacity-100"
leave-active-class="transition-all duration-300 ease-in-out"
leave-from-class="h-auto overflow-visible opacity-100"
leave-to-class="h-0 overflow-hidden opacity-0"
>
<div v-if="isUSCitizen === 'no'" class="flex flex-col gap-1">
<label class="mt-4">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.entityQuestion) }}
<span class="text-brand-red">*</span>
</span>
</label>
<Chips
v-model="entityType"
:items="['private-individual', 'foreign-entity']"
:format-label="
(item) =>
item === 'private-individual'
? formatMessage(messages.privateIndividual)
: formatMessage(messages.foreignEntity)
"
:never-empty="false"
:capitalize="false"
class="mt-2"
/>
<span class="text-md mt-2 leading-tight">
{{ formatMessage(messages.entityDescription) }}
</span>
</div>
</Transition>
<div class="mt-4 flex justify-end gap-3">
<ButtonStyled @click="handleCancel">
<button><XIcon /> {{ formatMessage(messages.cancel) }}</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="!canContinue || loading" @click="continueForm">
{{ formatMessage(messages.continue) }}
<RightArrowIcon v-if="!loading" />
<SpinnerIcon v-else class="animate-spin" />
</button>
</ButtonStyled>
</div>
</div>
<div v-else-if="currentStage === 'download-confirmation'" class="flex flex-col gap-6">
<div class="relative block h-[180px] w-[400px] overflow-hidden rounded-xl rounded-b-none">
<div
class="absolute inset-0 rounded-xl rounded-b-none bg-gradient-to-r from-brand-green to-brand-blue"
></div>
<div
class="absolute inset-0 rounded-xl rounded-b-none"
style="
background: linear-gradient(
180deg,
rgba(39, 41, 46, 0.15) 0%,
var(--color-raised-bg) 100%
);
"
></div>
<BrowserWindowSuccessIllustration
class="absolute left-[90px] top-[48px] h-[140px] w-[220px]"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-2xl font-semibold text-contrast">{{
formatMessage(messages.confirmationTitle)
}}</span>
<span>{{
formatMessage(messages.confirmationSuccess, { formType: determinedFormType })
}}</span>
<IntlFormatted :message-id="messages.confirmationSupportText">
<template #support-link="{ children }">
<nuxt-link
to="https://support.modrinth.com"
class="text-link"
target="_blank"
rel="noopener noreferrer"
>
<component :is="() => normalizeChildren(children)" />
</nuxt-link>
</template>
</IntlFormatted>
</div>
<div class="flex w-full flex-row justify-stretch gap-2">
<ButtonStyled>
<button class="w-full text-contrast" @click="handleClose">{{ closeButtonText }}</button>
</ButtonStyled>
<ButtonStyled color="green">
<button class="w-full text-contrast" @click="downloadTaxForm">
<DownloadIcon />{{
formatMessage(messages.downloadButton, { formType: determinedFormType })
}}
</button>
</ButtonStyled>
</div>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ExternalIcon, RightArrowIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
BrowserWindowSuccessIllustration,
DownloadIcon,
ExternalIcon,
RightArrowIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { Admonition, ButtonStyled, Chips, injectNotificationManager, NewModal } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
@@ -91,19 +163,41 @@ import { IntlFormatted } from '@vintl/vintl/components'
import { type FormRequestResponse, useAvalara1099 } from '@/composables/avalara1099'
import { normalizeChildren } from '@/utils/vue-children.ts'
const props = withDefaults(
defineProps<{
closeButtonText?: string
emitSuccessOnClose?: boolean
}>(),
{
closeButtonText: 'Close',
emitSuccessOnClose: true,
},
)
const { addNotification } = injectNotificationManager()
const taxFormModal = ref<InstanceType<typeof NewModal> | null>(null)
type ModalStage = 'form-selection' | 'download-confirmation'
const currentStage = ref<ModalStage>('form-selection')
async function startTaxForm(e: MouseEvent) {
currentStage.value = 'form-selection'
taxFormModal.value?.show(e)
}
async function showDownloadConfirmation(e: MouseEvent) {
currentStage.value = 'download-confirmation'
taxFormModal.value?.show(e)
}
defineExpose({
startTaxForm,
showDownloadConfirmation,
})
const auth = await useAuth()
const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
@@ -146,6 +240,23 @@ const messages = defineMessages({
},
cancel: { id: 'action.cancel', defaultMessage: 'Cancel' },
continue: { id: 'action.continue', defaultMessage: 'Continue' },
confirmationTitle: {
id: 'dashboard.creator-tax-form-modal.confirmation.title',
defaultMessage: "You're all set! 🎉",
},
confirmationSuccess: {
id: 'dashboard.creator-tax-form-modal.confirmation.success',
defaultMessage: 'Your {formType} tax form has been submitted successfully!',
},
confirmationSupportText: {
id: 'dashboard.creator-tax-form-modal.confirmation.support-text',
defaultMessage:
'You can freely withdraw now. If you have questions or need to update your details <support-link>contact support</support-link>.',
},
downloadButton: {
id: 'dashboard.creator-tax-form-modal.confirmation.download-button',
defaultMessage: 'Download {formType}',
},
})
const isUSCitizen = ref<'yes' | 'no' | null>(null)
@@ -159,6 +270,19 @@ function hideModal() {
function handleCancel() {
emit('cancelled')
hideModal()
setTimeout(() => {
currentStage.value = 'form-selection'
}, 300)
}
function handleClose() {
if (currentStage.value === 'download-confirmation' && props.emitSuccessOnClose) {
emit('success')
}
hideModal()
setTimeout(() => {
currentStage.value = 'form-selection'
}, 300)
}
const determinedFormType = computed(() => {
@@ -186,6 +310,7 @@ const emit = defineEmits<{
}>()
const avalaraState = ref<ReturnType<typeof useAvalara1099> | null>(null)
const formResponse = ref<any>(null)
const manualLoading = ref(false)
const loading = computed(
() =>
@@ -199,6 +324,13 @@ async function continueForm() {
manualLoading.value = true
// Skip Avalara if testTaxForm flag is enabled
if (flags.value.testTaxForm) {
currentStage.value = 'download-confirmation'
manualLoading.value = false
return
}
const response = (await useBaseFetch('payout/compliance', {
apiVersion: 3,
method: 'POST',
@@ -219,15 +351,11 @@ async function continueForm() {
try {
if (avalaraState.value) {
await avalaraState.value.start()
const response = await avalaraState.value.start()
formResponse.value = response
if (avalaraState.value.status === 'signed') {
addNotification({
title: 'Tax form submitted',
text: 'You can now withdraw your full balance.',
type: 'success',
})
emit('success')
hideModal()
currentStage.value = 'download-confirmation'
manualLoading.value = false
return
}
@@ -245,6 +373,15 @@ async function continueForm() {
}
}
function downloadTaxForm() {
if (!formResponse.value) return
const signedPdfUrl = formResponse.value.links?.signed_pdf
if (signedPdfUrl) {
window.open(signedPdfUrl, '_blank')
}
}
watch(isUSCitizen, (newValue) => {
if (newValue === 'yes') {
entityType.value = null
@@ -280,6 +417,7 @@ dialog[open] > iframe[src*='form_embed'] {
height: 95vh !important;
border-radius: var(--radius-md) !important;
}
dialog[open] > iframe[src*='form_embed'] {
border-radius: var(--radius-md) !important;
}