feat: added coupon code functionality
This commit is contained in:
157
frontend/src/components/Settings/CouponDetails.vue
Normal file
157
frontend/src/components/Settings/CouponDetails.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<Dialog v-model="show" :options="{ title: dialogTitle, size: '3xl' }">
|
||||
<template #body-content>
|
||||
<div class="grid grid-cols-2 gap-4 pt-4">
|
||||
<FormControl v-model="doc.code" :label="__('Coupon Code')" :required="true" pattern="^[A-Za-z0-9]+$" minlength="6" @beforeinput="handleCodeInput" @input="doc.code = $event.target.value.toUpperCase()" />
|
||||
<FormControl
|
||||
v-model="doc.discount_type"
|
||||
:label="__('Discount Type')"
|
||||
:required="true"
|
||||
type="select"
|
||||
:options="['Percent', 'Amount']"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="doc.discount_type === 'Percent'"
|
||||
v-model="doc.percent_off"
|
||||
:required="true"
|
||||
:label="__('Discount Percentage')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
v-model="doc.amount_off"
|
||||
:required="true"
|
||||
:label="__('Discount Amount')"
|
||||
type="number"
|
||||
/>
|
||||
<FormControl v-model="doc.expires_on" :label="__('Expires On')" type="date" />
|
||||
<FormControl v-model="doc.usage_limit" :label="__('Usage Limit')" type="number" />
|
||||
<Switch v-model="doc.active" :label="__('Active')" />
|
||||
<div class="col-span-2">
|
||||
<div class="text-sm font-medium text-ink-gray-7 mb-2">{{ __('Applicable Items (optional)') }}</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(row, idx) in doc.applicable_items" :key="idx" class="flex gap-2 items-center">
|
||||
<FormControl v-model="row.reference_doctype" :label="__('Type')" type="select" :options="['LMS Course', 'LMS Batch']" />
|
||||
<Link :doctype="row.reference_doctype || 'LMS Course'" :label="__('Item')" :value="row.reference_name" @change="(opt) => (row.reference_name = opt)" />
|
||||
<Button variant="subtle" @click="removeRow(idx)">
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="mt-2" @click="addRow">
|
||||
<template #prefix><Plus class="h-3 w-3" /></template>
|
||||
{{ __('Add Item') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="pb-5 float-right space-x-2">
|
||||
<Button variant="outline" @click="show = false">{{ __('Cancel') }}</Button>
|
||||
<Button variant="solid" @click="save">{{ __('Save') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Dialog, Button, FormControl, createResource, toast } from 'frappe-ui'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { Plus, X } from 'lucide-vue-next'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
|
||||
const show = defineModel({ type: Boolean, required: true })
|
||||
const props = defineProps({ couponId: String })
|
||||
const emit = defineEmits(['saved'])
|
||||
|
||||
const doc = ref({
|
||||
code: '',
|
||||
discount_type: 'Percent',
|
||||
percent_off: null,
|
||||
amount_off: null,
|
||||
active: 1,
|
||||
expires_on: null,
|
||||
usage_limit: null,
|
||||
applicable_items: [],
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() => (props.couponId === 'new' ? __('New Coupon') : __('Edit Coupon')))
|
||||
|
||||
const getDoc = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams() {
|
||||
return { doctype: 'LMS Coupon', name: props.couponId }
|
||||
},
|
||||
onSuccess(data) {
|
||||
doc.value = data
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => show.value,
|
||||
(val) => {
|
||||
if (val) {
|
||||
if (props.couponId && props.couponId !== 'new') {
|
||||
getDoc.submit()
|
||||
} else {
|
||||
doc.value = { code: '', discount_type: 'Percent', active: 1, applicable_items: [] }
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function addRow() {
|
||||
doc.value.applicable_items.push({ reference_doctype: 'LMS Course', reference_name: null })
|
||||
}
|
||||
function removeRow(idx) {
|
||||
doc.value.applicable_items.splice(idx, 1)
|
||||
}
|
||||
|
||||
const saveDoc = createResource({
|
||||
url: 'frappe.client.save',
|
||||
makeParams(values) {
|
||||
return { doc: doc.value }
|
||||
},
|
||||
})
|
||||
|
||||
const insertDoc = createResource({
|
||||
url: 'frappe.client.insert',
|
||||
makeParams(values) {
|
||||
return { doc: { doctype: 'LMS Coupon', ...doc.value } }
|
||||
},
|
||||
})
|
||||
|
||||
function handleCodeInput(event) {
|
||||
if (event.data && !/^[A-Za-z0-9]*$/.test(event.data)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (props.couponId && props.couponId !== 'new') {
|
||||
saveDoc.submit({}, {
|
||||
onSuccess() {
|
||||
toast.success(__('Saved'))
|
||||
show.value = false
|
||||
emit('saved')
|
||||
},
|
||||
onError(err) {
|
||||
console.log('Save error:', err)
|
||||
toast.error(err.messages?.[0] || err.message || err)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
insertDoc.submit({}, {
|
||||
onSuccess() {
|
||||
toast.success(__('Saved'))
|
||||
show.value = false
|
||||
emit('saved')
|
||||
},
|
||||
onError(err) {
|
||||
console.log('Insert error:', err)
|
||||
toast.error(err.messages?.[0] || err.message || err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
92
frontend/src/components/Settings/Coupons.vue
Normal file
92
frontend/src/components/Settings/Coupons.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-col text-base">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<div class="text-xl font-semibold mb-1 text-ink-gray-9">
|
||||
{{ __(label) }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 leading-5">
|
||||
{{ __(description) }}
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="openForm('new')">
|
||||
<template #prefix>
|
||||
<Plus class="h-3 w-3 stroke-1.5" />
|
||||
</template>
|
||||
{{ __('New') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="coupons.data?.length" class="overflow-y-scroll">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-surface-gray-2 text-ink-gray-7">
|
||||
<th class="text-left p-2">{{ __('Code') }}</th>
|
||||
<th class="text-left p-2">{{ __('Type') }}</th>
|
||||
<th class="text-left p-2">{{ __('Value') }}</th>
|
||||
<th class="text-left p-2">{{ __('Expires On') }}</th>
|
||||
<th class="text-left p-2">{{ __('Usage') }}</th>
|
||||
<th class="text-left p-2">{{ __('Active') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in coupons.data" :key="row.name" class="hover:bg-surface-gray-2 cursor-pointer" @click="openForm(row.name)">
|
||||
<td class="p-2">{{ row.code }}</td>
|
||||
<td class="p-2">{{ row.discount_type }}</td>
|
||||
<td class="p-2">
|
||||
<span v-if="row.discount_type === 'Percent'">{{ row.percent_off }}%</span>
|
||||
<span v-else>{{ row.amount_off }}</span>
|
||||
</td>
|
||||
<td class="p-2">{{ row.expires_on || '-' }}</td>
|
||||
<td class="p-2">{{ row.times_redeemed }}/{{ row.usage_limit || '∞' }}</td>
|
||||
<td class="p-2">
|
||||
<Badge v-if="row.active" theme="green">{{ __('Enabled') }}</Badge>
|
||||
<Badge v-else theme="gray">{{ __('Disabled') }}</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<CouponDetails v-model="showDialog" :coupon-id="selected" @saved="onSaved" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, Badge, createListResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
import { Plus } from 'lucide-vue-next'
|
||||
import CouponDetails from '@/components/Settings/CouponDetails.vue'
|
||||
|
||||
defineProps({
|
||||
label: String,
|
||||
description: String,
|
||||
})
|
||||
|
||||
const showDialog = ref(false)
|
||||
const selected = ref(null)
|
||||
|
||||
const coupons = createListResource({
|
||||
doctype: 'LMS Coupon',
|
||||
fields: [
|
||||
'name',
|
||||
'code',
|
||||
'discount_type',
|
||||
'percent_off',
|
||||
'amount_off',
|
||||
'expires_on',
|
||||
'usage_limit',
|
||||
'times_redeemed',
|
||||
'active',
|
||||
],
|
||||
})
|
||||
|
||||
function openForm(id) {
|
||||
selected.value = id
|
||||
showDialog.value = true
|
||||
}
|
||||
|
||||
function onSaved() {
|
||||
coupons.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -81,6 +81,7 @@ import Categories from '@/components/Settings/Categories.vue'
|
||||
import EmailTemplates from '@/components/Settings/EmailTemplates.vue'
|
||||
import BrandSettings from '@/components/Settings/BrandSettings.vue'
|
||||
import PaymentGateways from '@/components/Settings/PaymentGateways.vue'
|
||||
import Coupons from '@/components/Settings/Coupons.vue'
|
||||
import Transactions from '@/components/Settings/Transactions.vue'
|
||||
import ZoomSettings from '@/components/Settings/ZoomSettings.vue'
|
||||
import Badges from '@/components/Settings/Badges.vue'
|
||||
@@ -230,6 +231,12 @@ const tabsStructure = computed(() => {
|
||||
template: markRaw(PaymentGateways),
|
||||
description: 'Add and manage all your payment gateways',
|
||||
},
|
||||
{
|
||||
label: 'Coupons',
|
||||
icon: 'Tag',
|
||||
template: markRaw(Coupons),
|
||||
description: 'Create and manage coupon codes',
|
||||
},
|
||||
{
|
||||
label: 'Transactions',
|
||||
icon: 'Landmark',
|
||||
|
||||
@@ -72,6 +72,40 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="transactionData && (transactionData.coupon || transactionData.discount_amount || transactionData.discount_percent)"
|
||||
class="mt-10"
|
||||
>
|
||||
<div class="font-semibold">
|
||||
{{ __('Coupon (if applied)') }}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<Link
|
||||
:label="__('Coupon')"
|
||||
v-model="transactionData.coupon"
|
||||
doctype="LMS Coupon"
|
||||
:disabled="true"
|
||||
/>
|
||||
<FormControl
|
||||
:label="__('Discount Type')"
|
||||
v-model="transactionData.discount_type"
|
||||
:disabled="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-if="transactionData.discount_type === 'Percent'"
|
||||
:label="__('Discount Percent')"
|
||||
v-model="transactionData.discount_percent"
|
||||
:disabled="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
:label="__('Discount Amount')"
|
||||
v-model="transactionData.discount_amount"
|
||||
:disabled="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-5 mt-5">
|
||||
<FormControl :label="__('GSTIN')" v-model="transactionData.gstin" />
|
||||
<FormControl :label="__('PAN')" v-model="transactionData.pan" />
|
||||
@@ -100,7 +134,7 @@
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { Dialog, FormControl, Button } from 'frappe-ui'
|
||||
import { Dialog, FormControl, Button, createResource } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ref, watch } from 'vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
@@ -115,10 +149,24 @@ const props = defineProps<{
|
||||
transaction: { [key: string]: any } | null
|
||||
}>()
|
||||
|
||||
const paymentDoc = createResource({
|
||||
url: 'frappe.client.get',
|
||||
makeParams() {
|
||||
return { doctype: 'LMS Payment', name: props.transaction?.name }
|
||||
},
|
||||
onSuccess(data) {
|
||||
transactionData.value = data
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.transaction,
|
||||
(newVal) => {
|
||||
transactionData.value = newVal ? { ...newVal } : null
|
||||
if (newVal?.name) {
|
||||
paymentDoc.submit()
|
||||
} else {
|
||||
transactionData.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -46,6 +46,10 @@
|
||||
{{ orderSummary.data.gst_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="orderSummary.data.discount_amount" class="flex items-center justify-between mt-2">
|
||||
<div class="text-ink-gray-5">{{ __('Discount') }}</div>
|
||||
<div>-{{ orderSummary.data.discount_amount_formatted }}</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between border-t border-outline-gray-3 pt-4 mt-2"
|
||||
>
|
||||
@@ -56,6 +60,14 @@
|
||||
{{ orderSummary.data.total_amount_formatted }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
<div class="flex items-center gap-3 mt-2 flex-wrap md:flex-nowrap" v-if="props.type !== 'certificate'">
|
||||
<span class="text-ink-gray-5 text-xs shrink-0">{{ __('Coupon') }}</span>
|
||||
<FormControl class="flex-1 min-w-0 [&_input]:!bg-[#fefefe]" v-model="couponCode" @input="couponCode = $event.target.value.toUpperCase()"/>
|
||||
<Button @click="applyCouponCode" variant="outline">{{ __('Apply') }}</Button>
|
||||
<Button v-if="appliedCoupon" @click="removeCoupon" variant="subtle">{{ __('Remove') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 lg:mr-10">
|
||||
@@ -158,7 +170,7 @@ import {
|
||||
usePageMeta,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { reactive, inject, onMounted, computed } from 'vue'
|
||||
import { reactive, inject, onMounted, computed, ref } from 'vue'
|
||||
import { sessionStore } from '../stores/session'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import NotPermitted from '@/components/NotPermitted.vue'
|
||||
@@ -212,6 +224,29 @@ const orderSummary = createResource({
|
||||
},
|
||||
})
|
||||
|
||||
const couponCode = ref('')
|
||||
const appliedCoupon = ref(null)
|
||||
|
||||
const applyCoupon = createResource({
|
||||
url: 'lms.lms.utils.apply_coupon',
|
||||
makeParams() {
|
||||
return {
|
||||
doctype: props.type == 'batch' ? 'LMS Batch' : 'LMS Course',
|
||||
docname: props.name,
|
||||
code: couponCode.value,
|
||||
country: billingDetails.country,
|
||||
}
|
||||
},
|
||||
onSuccess(data) {
|
||||
orderSummary.data = data
|
||||
appliedCoupon.value = couponCode.value
|
||||
toast.success(__('Coupon applied'))
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err.messages?.[0] || err)
|
||||
},
|
||||
})
|
||||
|
||||
const billingDetails = reactive({})
|
||||
|
||||
const setBillingDetails = (data) => {
|
||||
@@ -241,6 +276,7 @@ const paymentLink = createResource({
|
||||
address: billingDetails,
|
||||
redirect_to: redirectTo.value,
|
||||
payment_for_certificate: props.type == 'certificate',
|
||||
coupon_code: appliedCoupon.value,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -265,6 +301,16 @@ const generatePaymentLink = () => {
|
||||
)
|
||||
}
|
||||
|
||||
function applyCouponCode() {
|
||||
if (!couponCode.value) return
|
||||
applyCoupon.submit()
|
||||
}
|
||||
|
||||
function removeCoupon() {
|
||||
appliedCoupon.value = null
|
||||
orderSummary.reload()
|
||||
}
|
||||
|
||||
const validateAddress = () => {
|
||||
let mandatoryFields = [
|
||||
'billing_name',
|
||||
|
||||
Reference in New Issue
Block a user