Files
enlight-lms/frontend/src/pages/Billing.vue

462 lines
12 KiB
Vue

<template>
<div class="">
<header
class="sticky top-0 z-10 flex items-center justify-between border-b bg-surface-white px-3 py-2.5 sm:px-5"
>
<Breadcrumbs
class="h-7"
:items="[{ label: __('Billing Details'), route: { name: 'Billing' } }]"
/>
</header>
<div
v-if="access.data?.access && orderSummary.data"
class="pt-5 pb-10 mx-5"
>
<div class="flex flex-col lg:flex-row justify-between">
<div class="flex flex-col lg:order-last mb-10 lg:mt-10 lg:w-1/4">
<div class="h-fit bg-surface-gray-2 rounded-md p-5 space-y-4">
<div class="space-y-1">
<div class="text-ink-gray-5 uppercase text-xs">
{{ __('Payment for ') }} {{ type }}:
</div>
<div class="leading-5 text-ink-gray-9">
{{ orderSummary.data.title }}
</div>
</div>
<div
v-if="
orderSummary.data.gst_applied ||
orderSummary.data.discount_amount
"
class="space-y-1"
>
<div class="text-ink-gray-5 uppercase text-xs">
{{ __('Original Amount') }}:
</div>
<div class="text-ink-gray-9">
{{ orderSummary.data.original_amount_formatted }}
</div>
</div>
<div v-if="orderSummary.data.discount_amount" class="space-y-1">
<div class="text-ink-gray-5">{{ __('Discount') }}:</div>
<div>- {{ orderSummary.data.discount_amount_formatted }}</div>
</div>
<div v-if="orderSummary.data.gst_applied" class="space-y-1">
<div class="text-ink-gray-5 uppercase text-xs">
{{ __('GST Amount') }}:
</div>
<div class="text-ink-gray-9">
{{ orderSummary.data.gst_amount_formatted }}
</div>
</div>
<div class="space-y-1 border-t border-outline-gray-3 pt-4 mt-2">
<div class="uppercase text-ink-gray-5 text-xs">
{{ __('Total') }}:
</div>
<div class="font-bold text-ink-gray-9">
{{ orderSummary.data.total_amount_formatted }}
</div>
</div>
</div>
<div class="bg-surface-gray-2 rounded-md p-4 space-y-2 my-5">
<span class="text-ink-gray-5 uppercase text-xs">
{{ __('Enter a Coupon Code') }}:
</span>
<div class="flex items-center space-x-2">
<FormControl
v-model="appliedCoupon"
:disabled="orderSummary.data.discount_amount > 0"
@input="appliedCoupon = $event.target.value.toUpperCase()"
@keydown.enter="applyCouponCode"
placeholder="COUPON2025"
autocomplete="off"
class="flex-1 [&_input]:bg-white"
/>
<Button
v-if="!orderSummary.data.discount_amount"
@click="applyCouponCode"
variant="outline"
>
{{ __('Apply') }}
</Button>
<Button
v-if="orderSummary.data.discount_amount"
@click="removeCoupon"
variant="outline"
>
<template #icon>
<X class="size-4 stroke-1.5" />
</template>
</Button>
</div>
</div>
<p
class="bg-surface-amber-2 text-ink-amber-2 text-sm leading-5 p-2 rounded-md"
>
{{
__(
'Please ensure that the billing name you enter is correct, as it will be used on your invoice.'
)
}}
</p>
</div>
<div class="flex-1 lg:mr-10">
<div class="mb-5">
<div class="text-lg font-semibold text-ink-gray-9">
{{ __('Address') }}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
<div class="space-y-4">
<FormControl
:label="__('Billing Name')"
v-model="billingDetails.billing_name"
:required="true"
/>
<FormControl
:label="__('Address Line 1')"
v-model="billingDetails.address_line1"
:required="true"
/>
<FormControl
:label="__('Address Line 2')"
v-model="billingDetails.address_line2"
/>
<FormControl
:label="__('City')"
v-model="billingDetails.city"
:required="true"
/>
<FormControl
:label="__('State/Province')"
v-model="billingDetails.state"
/>
</div>
<div class="space-y-4">
<Link
doctype="Country"
:value="billingDetails.country"
@change="(option) => changeCurrency(option)"
:label="__('Country')"
:required="true"
/>
<FormControl
:label="__('Postal Code')"
v-model="billingDetails.pincode"
:required="true"
/>
<FormControl
:label="__('Phone Number')"
v-model="billingDetails.phone"
:required="true"
/>
<Link
doctype="LMS Source"
:value="billingDetails.source"
@change="(option) => (billingDetails.source = option)"
:label="__('Where did you hear about us?')"
:required="true"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('GST Number')"
v-model="billingDetails.gstin"
/>
<FormControl
v-if="billingDetails.country == 'India'"
:label="__('PAN Number')"
v-model="billingDetails.pan"
/>
</div>
</div>
<div
class="flex flex-col lg:flex-row items-start lg:items-center justify-between border-t pt-4 mt-8 space-y-4 lg:space-y-0"
>
<div>
<FormControl
:label="
__(
'I consent to my personal information being stored for invoicing'
)
"
type="checkbox"
class="leading-6"
v-model="billingDetails.member_consent"
/>
<div
v-if="showConsentWarning"
class="mt-1 text-xs text-ink-red-3"
>
{{
__('Please provide your consent to proceed with the payment')
}}
</div>
</div>
<Button variant="solid" size="md" @click="generatePaymentLink()">
{{ __('Proceed to Payment') }}
</Button>
</div>
</div>
</div>
</div>
<div v-else-if="access.data?.message">
<NotPermitted
:text="access.data.message"
:buttonLabel="type == 'course' ? 'Checkout Course' : 'Checkout Batch'"
:buttonLink="
type == 'course' ? `/lms/courses/${name}` : `/lms/batches/${name}`
"
/>
</div>
<div v-else-if="!user.data?.name">
<NotPermitted
text="Please login to access this page."
:buttonLink="`/login?redirect-to=/lms/billing/${type}/${name}`"
/>
</div>
</div>
</template>
<script setup>
import {
Button,
createResource,
FormControl,
Breadcrumbs,
usePageMeta,
toast,
call,
} from 'frappe-ui'
import { reactive, inject, onMounted, computed, ref, watch } from 'vue'
import { sessionStore } from '../stores/session'
import Link from '@/components/Controls/Link.vue'
import NotPermitted from '@/components/NotPermitted.vue'
import { X } from 'lucide-vue-next'
const user = inject('$user')
const { brand } = sessionStore()
const showConsentWarning = ref(false)
onMounted(() => {
const script = document.createElement('script')
script.src = `https://checkout.razorpay.com/v1/checkout.js`
document.body.appendChild(script)
if (user.data?.name) {
access.submit()
}
})
const props = defineProps({
type: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
})
const access = createResource({
url: 'lms.lms.api.validate_billing_access',
params: {
billing_type: props.type,
name: props.name,
},
onSuccess(data) {
setBillingDetails(data.address)
orderSummary.submit()
},
})
const orderSummary = createResource({
url: 'lms.lms.utils.get_order_summary',
makeParams(values) {
return {
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
docname: props.name,
country: billingDetails.country,
coupon: appliedCoupon.value,
}
},
onError(err) {
showError(err)
},
})
const appliedCoupon = ref(null)
const billingDetails = reactive({})
const setBillingDetails = (data) => {
billingDetails.billing_name = data?.billing_name || ''
billingDetails.address_line1 = data?.address_line1 || ''
billingDetails.address_line2 = data?.address_line2 || ''
billingDetails.city = data?.city || ''
billingDetails.state = data?.state || ''
billingDetails.country = data?.country || ''
billingDetails.pincode = data?.pincode || ''
billingDetails.phone = data?.phone || ''
billingDetails.source = data?.source || ''
billingDetails.gstin = data?.gstin || ''
billingDetails.pan = data?.pan || ''
}
const paymentLink = createResource({
url: 'lms.lms.payments.get_payment_link',
makeParams(values) {
let data = {
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
docname: props.name,
title: orderSummary.data.title,
amount: orderSummary.data.original_amount,
discount_amount: orderSummary.data.discount_amount || 0,
gst_amount: orderSummary.data.gst_applied || 0,
currency: orderSummary.data.currency,
address: billingDetails,
redirect_to: redirectTo.value,
payment_for_certificate: props.type == 'certificate',
coupon_code: appliedCoupon.value,
coupon: orderSummary.data.coupon,
}
return data
},
})
const generatePaymentLink = () => {
paymentLink.submit(
{},
{
validate() {
if (!billingDetails.source) {
return __('Please let us know where you heard about us from.')
}
if (!billingDetails.member_consent) {
showConsentWarning.value = true
return __('Please provide your consent to proceed with the payment.')
}
return validateAddress()
},
onSuccess(data) {
window.location.href = data
},
onError(err) {
toast.error(err.messages?.[0] || err)
},
}
)
}
function applyCouponCode() {
if (!appliedCoupon.value) {
toast.error(__('Please enter a coupon code'))
return
}
orderSummary.reload()
}
function removeCoupon() {
appliedCoupon.value = null
orderSummary.reload()
}
const validateAddress = () => {
let mandatoryFields = [
'billing_name',
'address_line1',
'city',
'pincode',
'country',
'phone',
'source',
]
for (let field of mandatoryFields) {
if (!billingDetails[field])
return (
'Please enter a valid ' +
field
.replaceAll('_', ' ')
.toLowerCase()
.replace(/\b\w/g, (s) => s.toUpperCase())
)
}
if (billingDetails.gstin && !billingDetails.pan)
return 'Please enter a valid pan number.'
if (billingDetails.country == 'India' && !billingDetails.state)
return 'Please enter a valid state with correct spelling and the first letter capitalized.'
const states = [
'Andhra Pradesh',
'Arunachal Pradesh',
'Assam',
'Bihar',
'Chhattisgarh',
'Delhi',
'Goa',
'Gujarat',
'Haryana',
'Himachal Pradesh',
'Jammu and Kashmir',
'Jharkhand',
'Karnataka',
'Kerala',
'Madhya Pradesh',
'Maharashtra',
'Manipur',
'Meghalaya',
'Mizoram',
'Nagaland',
'Odisha',
'Punjab',
'Rajasthan',
'Sikkim',
'Tamil Nadu',
'Telangana',
'Tripura',
'Uttar Pradesh',
'Uttarakhand',
'West Bengal',
]
if (
billingDetails.country == 'India' &&
!states.includes(billingDetails.state)
)
return 'Please enter a valid state with correct spelling and the first letter capitalized.'
}
const showError = (err) => {
toast.error(err.messages?.[0] || err)
}
const changeCurrency = (country) => {
billingDetails.country = country
orderSummary.reload()
}
const redirectTo = computed(() => {
if (props.type == 'course') {
return `/lms/courses/${props.name}`
} else if (props.type == 'batch') {
return `/lms/batches/${props.name}`
} else if (props.type == 'certificate') {
return `/lms/courses/${props.name}/certification`
}
})
watch(billingDetails, () => {
if (billingDetails.member_consent) {
showConsentWarning.value = false
}
})
usePageMeta(() => {
return {
title: __('Billing Details'),
icon: brand.favicon,
}
})
</script>