feat: added coupon code functionality

This commit is contained in:
Joedeep Singh
2025-10-12 17:07:42 +00:00
parent 9d4196f15a
commit bf36890bd3
17 changed files with 821 additions and 23 deletions

View 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>

View 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>

View File

@@ -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',

View File

@@ -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 }
)

View File

@@ -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',