feat: batch billing
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="communications.data">
|
||||
<div v-if="communications.data?.length">
|
||||
<div v-for="comm in communications.data">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
@@ -20,6 +20,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm italic text-gray-600">
|
||||
{{ __('No announcements') }}
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createListResource, Avatar } from 'frappe-ui'
|
||||
|
||||
@@ -50,15 +50,23 @@
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
<router-link
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
type: 'batch',
|
||||
name: batch.data.name,
|
||||
},
|
||||
}"
|
||||
v-else-if="batch.data.paid_batch"
|
||||
class="w-full mt-4"
|
||||
variant="solid"
|
||||
>
|
||||
<span>
|
||||
{{ __('Register Now') }}
|
||||
</span>
|
||||
</Button>
|
||||
<Button class="w-full mt-4" variant="solid">
|
||||
<span>
|
||||
{{ __('Register Now') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
|
||||
<Button v-if="user?.data?.is_moderator" class="w-full mt-2">
|
||||
<span>
|
||||
{{ __('Edit') }}
|
||||
|
||||
@@ -77,6 +77,7 @@ const valuePropPassed = computed(() => 'value' in attrs)
|
||||
const value = computed({
|
||||
get: () => (valuePropPassed.value ? attrs.value : props.modelValue),
|
||||
set: (val) => {
|
||||
console.log(val?.value, valuePropPassed.value)
|
||||
return (
|
||||
val?.value &&
|
||||
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
|
||||
|
||||
@@ -27,6 +27,22 @@
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else-if="course.data.paid_course"
|
||||
:to="{
|
||||
name: 'Billing',
|
||||
params: {
|
||||
type: 'course',
|
||||
name: course.data.name,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<Button variant="solid" class="w-full mb-3">
|
||||
<span>
|
||||
{{ __('Buy this course') }}
|
||||
</span>
|
||||
</Button>
|
||||
</router-link>
|
||||
<Button
|
||||
v-else
|
||||
@click="enrollStudent()"
|
||||
|
||||
44
frontend/src/components/NotPermitted.vue
Normal file
44
frontend/src/components/NotPermitted.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-red-600 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
></span>
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div class="mb-4 leading-6">
|
||||
{{ __(text) }}
|
||||
</div>
|
||||
<Button variant="solid" class="w-full" @click="redirect()">
|
||||
{{ __(buttonLabel) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Not Permitted',
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: 'You are not permitted to access this page.',
|
||||
},
|
||||
buttonLabel: {
|
||||
type: String,
|
||||
default: 'Login',
|
||||
},
|
||||
buttonLink: {
|
||||
type: String,
|
||||
default: '/login',
|
||||
},
|
||||
})
|
||||
|
||||
const redirect = () => {
|
||||
window.location.href = props.buttonLink
|
||||
}
|
||||
</script>
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="text-lg font-semibold mb-4">
|
||||
{{ __('Upcoming Evaluations') }}
|
||||
</div>
|
||||
<div v-if="upcoming_evals.data">
|
||||
<div v-if="upcoming_evals.data?.length">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div v-for="evl in upcoming_evals.data">
|
||||
<div class="border rounded-md p-3">
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-screen">
|
||||
<div v-else-if="!user.data?.name" class="h-screen">
|
||||
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium">
|
||||
<span
|
||||
@@ -238,44 +238,41 @@ const isStudent = computed(() => {
|
||||
})
|
||||
|
||||
const tabIndex = ref(0)
|
||||
const tabs = []
|
||||
|
||||
if (isStudent.value) {
|
||||
tabs.push({
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
const tabs = computed(() => {
|
||||
let batchTabs = []
|
||||
if (isStudent.value) {
|
||||
batchTabs.push({
|
||||
label: 'Dashboard',
|
||||
icon: LayoutDashboard,
|
||||
})
|
||||
}
|
||||
if (user.data?.is_moderator) {
|
||||
batchTabs.push({
|
||||
label: 'Students',
|
||||
icon: Contact2,
|
||||
})
|
||||
batchTabs.push({
|
||||
label: 'Assessments',
|
||||
icon: BookOpenCheck,
|
||||
})
|
||||
}
|
||||
batchTabs.push({
|
||||
label: 'Live Class',
|
||||
icon: Laptop,
|
||||
})
|
||||
}
|
||||
|
||||
if (user.data?.is_moderator) {
|
||||
tabs.push({
|
||||
label: 'Students',
|
||||
icon: Contact2,
|
||||
batchTabs.push({
|
||||
label: 'Courses',
|
||||
icon: BookOpen,
|
||||
})
|
||||
tabs.push({
|
||||
label: 'Assessments',
|
||||
icon: BookOpenCheck,
|
||||
batchTabs.push({
|
||||
label: 'Announcements',
|
||||
icon: Mail,
|
||||
})
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
label: 'Live Class',
|
||||
icon: Laptop,
|
||||
})
|
||||
|
||||
tabs.push({
|
||||
label: 'Courses',
|
||||
icon: BookOpen,
|
||||
})
|
||||
|
||||
tabs.push({
|
||||
label: 'Announcements',
|
||||
icon: Mail,
|
||||
})
|
||||
|
||||
tabs.push({
|
||||
label: 'Discussions',
|
||||
icon: MessageCircle,
|
||||
batchTabs.push({
|
||||
label: 'Discussions',
|
||||
icon: MessageCircle,
|
||||
})
|
||||
return batchTabs
|
||||
})
|
||||
|
||||
const courses = createResource({
|
||||
|
||||
@@ -78,12 +78,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
Breadcrumbs,
|
||||
createDocumentResource,
|
||||
createListResource,
|
||||
createResource,
|
||||
} from 'frappe-ui'
|
||||
import { Breadcrumbs, createResource } from 'frappe-ui'
|
||||
import { BookOpen, Calendar, Clock } from 'lucide-vue-next'
|
||||
import { formatTime } from '../utils'
|
||||
import { computed, inject } from 'vue'
|
||||
@@ -110,7 +105,7 @@ const batch = createResource({
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
if (data.students?.includes(user.data.name)) {
|
||||
if (data.students?.includes(user.data?.name)) {
|
||||
router.push({
|
||||
name: 'Batch',
|
||||
params: {
|
||||
|
||||
@@ -1,14 +1,64 @@
|
||||
<template>
|
||||
<div class="text-base h-screen">
|
||||
<div v-if="access.data?.access" class="mt-20 w-1/2 mx-auto">
|
||||
<div
|
||||
v-if="access.data?.access && orderSummary.data"
|
||||
class="mt-10 w-1/2 mx-auto"
|
||||
>
|
||||
<div class="text-3xl font-bold">
|
||||
{{ __('Billing Details') }}
|
||||
</div>
|
||||
<div class="text-gray-600 mt-1">
|
||||
{{ __('Enter the billing information to complete the payment.') }}
|
||||
</div>
|
||||
<div class="border rounded-md p-8 mt-10">
|
||||
<div class="border rounded-md p-5 mt-5">
|
||||
<div class="text-xl font-semibold">
|
||||
{{ __('Summary') }}
|
||||
</div>
|
||||
<div class="text-gray-600 mt-1">
|
||||
{{ __('Review the details of your purchase.') }}
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
{{ orderSummary.data.title }}
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'font-semibold text-xl': !orderSummary.data.gst_applied,
|
||||
}"
|
||||
>
|
||||
{{
|
||||
orderSummary.data.gst_applied
|
||||
? orderSummary.data.original_amount_formatted
|
||||
: orderSummary.data.total_amount_formatted
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderSummary.data.gst_applied"
|
||||
class="flex items-center justify-between mt-2"
|
||||
>
|
||||
<div>
|
||||
{{ __('GST Amount') }}
|
||||
</div>
|
||||
<div>
|
||||
{{ orderSummary.data.gst_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="orderSummary.data.gst_applied"
|
||||
class="flex items-center justify-between mt-2"
|
||||
>
|
||||
<div>
|
||||
{{ __('Total Amount') }}
|
||||
</div>
|
||||
<div class="font-semibold text-2xl">
|
||||
{{ orderSummary.data.total_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xl font-semibold mt-10">
|
||||
{{ __('Address') }}
|
||||
</div>
|
||||
<div class="text-gray-600 mt-1">
|
||||
@@ -26,13 +76,13 @@
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Address Line 1') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.address_line_1" />
|
||||
<Input type="text" v-model="billingDetails.address_line1" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Address Line 2') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.address_line_2" />
|
||||
<Input type="text" v-model="billingDetails.address_line2" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
@@ -52,7 +102,11 @@
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Country') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.country" />
|
||||
<Link
|
||||
doctype="Country"
|
||||
:value="billingDetails.country"
|
||||
@change="(option) => changeCurrency(option)"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
@@ -70,7 +124,11 @@
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
{{ __('Source') }}
|
||||
</div>
|
||||
<Input type="text" v-model="billingDetails.source" />
|
||||
<Link
|
||||
doctype="LMS Source"
|
||||
:value="billingDetails.source"
|
||||
@change="(option) => (billingDetails.source = option)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="billingDetails.country == 'India'" class="mt-4">
|
||||
<div class="mb-1.5 text-sm text-gray-700">
|
||||
@@ -86,62 +144,46 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="solid" class="mt-8">
|
||||
<Button variant="solid" class="mt-8" @click="generatePaymentLink()">
|
||||
{{ __('Proceed to Payment') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="text-base border rounded-md w-1/3 mx-auto my-32">
|
||||
<div class="border-b px-5 py-3 font-medium">
|
||||
<span
|
||||
class="inline-flex items-center before:bg-red-600 before:w-2 before:h-2 before:rounded-md before:mr-2"
|
||||
></span>
|
||||
{{ __('Not Permitted') }}
|
||||
</div>
|
||||
<div class="px-5 py-3">
|
||||
<div class="mb-4 leading-6">
|
||||
{{ access.data?.message }}
|
||||
</div>
|
||||
<Button
|
||||
v-if="!user.data.name"
|
||||
variant="solid"
|
||||
class="w-full"
|
||||
@click="redirectToLogin()"
|
||||
>
|
||||
{{ __('Login') }}
|
||||
</Button>
|
||||
<router-link
|
||||
v-else-if="type == 'course'"
|
||||
:to="{
|
||||
name: 'Courses',
|
||||
}"
|
||||
>
|
||||
<Button variant="solid">
|
||||
{{ __('Checkout Courses') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else-if="type == 'batch'"
|
||||
:to="{
|
||||
name: 'Batches',
|
||||
}"
|
||||
>
|
||||
<Button varian="solid">
|
||||
{{ __('Checkout Batches') }}
|
||||
</Button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="access.data?.message">
|
||||
<NotPermitted
|
||||
:text="access.data.message"
|
||||
:buttonLabel="
|
||||
type == 'course' ? 'Checkout Courses' : 'Checkout Batches'
|
||||
"
|
||||
:buttonLink="type == 'course' ? '/courses' : '/batches'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!user.data?.name">
|
||||
<NotPermitted
|
||||
text="Please login to access this page."
|
||||
:buttonLink="`/login?redirect-to=/billing/${type}/${name}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Input, Button, createResource } from 'frappe-ui'
|
||||
import { reactive, inject, onMounted, ref } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import NotPermitted from '@/components/NotPermitted.vue'
|
||||
import { createToast } from '@/utils/'
|
||||
|
||||
const user = inject('$user')
|
||||
|
||||
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,
|
||||
@@ -159,24 +201,189 @@ const access = createResource({
|
||||
type: props.type,
|
||||
name: props.name,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
orderSummary.submit()
|
||||
setBillingDetails(data.address)
|
||||
},
|
||||
})
|
||||
|
||||
const billingDetails = reactive({
|
||||
billing_name: '',
|
||||
address_line_1: '',
|
||||
address_line_2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
pincode: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
source: '',
|
||||
gstin: '',
|
||||
pan: '',
|
||||
const orderSummary = createResource({
|
||||
url: 'lms.lms.utils.get_order_summary',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||
docname: props.name,
|
||||
country: billingDetails.country,
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
showError(err)
|
||||
},
|
||||
})
|
||||
|
||||
const redirectToLogin = () => {
|
||||
window.location.href = `/login?redirect-to=/billing/${props.type}/${props.name}`
|
||||
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 paymentOptions = createResource({
|
||||
url: 'lms.lms.utils.get_payment_options',
|
||||
makeParams(values) {
|
||||
return {
|
||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||
docname: props.name,
|
||||
phone: billingDetails.phone,
|
||||
country: billingDetails.country,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const generatePaymentLink = () => {
|
||||
paymentOptions.submit(
|
||||
{},
|
||||
{
|
||||
validate(params) {
|
||||
return validateAddress()
|
||||
},
|
||||
onSuccess(data) {
|
||||
data.handler = (response) => {
|
||||
let doctype = props.type == 'course' ? 'LMS Course' : 'LMS Batch'
|
||||
let docname = props.name
|
||||
handleSuccess(response, doctype, docname, data.order_id)
|
||||
}
|
||||
let rzp1 = new Razorpay(data)
|
||||
rzp1.open()
|
||||
},
|
||||
onError(err) {
|
||||
showError(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const paymentResource = createResource({
|
||||
url: 'lms.lms.utils.verify_payment',
|
||||
makeParams(values) {
|
||||
return {
|
||||
response: values.response,
|
||||
doctype: props.type == 'course' ? 'LMS Course' : 'LMS Batch',
|
||||
docname: props.name,
|
||||
address: billingDetails,
|
||||
order_id: values.orderId,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleSuccess = (response, doctype, docname, orderId) => {
|
||||
paymentResource.submit(
|
||||
{
|
||||
response: response,
|
||||
orderId: orderId,
|
||||
},
|
||||
{
|
||||
onSuccess(data) {
|
||||
createToast({
|
||||
title: 'Success',
|
||||
text: 'Payment Successful',
|
||||
icon: 'check',
|
||||
iconClasses: 'bg-green-600 text-white rounded-md p-px',
|
||||
})
|
||||
setTimeout(() => {
|
||||
window.location.href = data
|
||||
}, 3000)
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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',
|
||||
'Goa',
|
||||
'Gujarat',
|
||||
'Haryana',
|
||||
'Himachal Pradesh',
|
||||
'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) => {
|
||||
createToast({
|
||||
title: 'Error',
|
||||
text: err.messages?.[0] || err,
|
||||
icon: 'x',
|
||||
iconClasses: 'bg-red-600 text-white rounded-md p-px',
|
||||
position: 'top-center',
|
||||
timeout: 10,
|
||||
})
|
||||
}
|
||||
|
||||
const changeCurrency = (country) => {
|
||||
billingDetails.country = country
|
||||
orderSummary.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user