Skip to content

Commit

Permalink
feat(coupons): adding coupon run and clear operations
Browse files Browse the repository at this point in the history
  • Loading branch information
tomgobich committed Dec 22, 2024
1 parent 44442e5 commit 1231adf
Show file tree
Hide file tree
Showing 9 changed files with 355 additions and 14 deletions.
57 changes: 51 additions & 6 deletions .adonisjs/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,24 +150,24 @@ type RolesIdDelete = {
response: MakeTuyauResponse<import('../app/controllers/roles_controller.ts').default['destroy'], false>
}
type PlansGetHead = {
request: unknown
response: MakeTuyauResponse<import('../app/controllers/plans_controller.ts').default['index'], false>
request: MakeTuyauRequest<InferInput<typeof import('../app/validators/plan.ts')['planIndexValidator']>>
response: MakeTuyauResponse<import('../app/controllers/plans_controller.ts').default['index'], true>
}
type PlansCreateGetHead = {
request: unknown
response: MakeTuyauResponse<import('../app/controllers/plans_controller.ts').default['create'], false>
}
type PlansPost = {
request: unknown
response: MakeTuyauResponse<import('../app/controllers/plans_controller.ts').default['store'], false>
request: MakeTuyauRequest<InferInput<typeof import('../app/validators/plan.ts')['planValidator']>>
response: MakeTuyauResponse<import('../app/controllers/plans_controller.ts').default['store'], true>
}
type PlansIdEditGetHead = {
request: unknown
response: MakeTuyauResponse<import('../app/controllers/plans_controller.ts').default['edit'], false>
}
type PlansIdPut = {
request: unknown
response: MakeTuyauResponse<import('../app/controllers/plans_controller.ts').default['update'], false>
request: MakeTuyauRequest<InferInput<typeof import('../app/validators/plan.ts')['planValidator']>>
response: MakeTuyauResponse<import('../app/controllers/plans_controller.ts').default['update'], true>
}
type PlansIdActivatePatch = {
request: unknown
Expand All @@ -181,6 +181,18 @@ type PlansIdDelete = {
request: unknown
response: MakeTuyauResponse<import('../app/controllers/plans_controller.ts').default['destroy'], false>
}
type CouponsCreateGetHead = {
request: unknown
response: MakeTuyauResponse<import('../app/controllers/coupons_controller.ts').default['create'], false>
}
type CouponsPost = {
request: MakeTuyauRequest<InferInput<typeof import('../app/validators/coupon.ts')['couponValidator']>>
response: MakeTuyauResponse<import('../app/controllers/coupons_controller.ts').default['run'], true>
}
type CouponsDelete = {
request: unknown
response: MakeTuyauResponse<import('../app/controllers/coupons_controller.ts').default['clear'], false>
}
export interface ApiDefinition {
'login': {
'$url': {
Expand Down Expand Up @@ -386,6 +398,18 @@ export interface ApiDefinition {
'$delete': PlansIdDelete;
};
};
'coupons': {
'create': {
'$url': {
};
'$get': CouponsCreateGetHead;
'$head': CouponsCreateGetHead;
};
'$url': {
};
'$post': CouponsPost;
'$delete': CouponsDelete;
};
}
const routes = [
{
Expand Down Expand Up @@ -717,6 +741,27 @@ const routes = [
method: ["DELETE"],
types: {} as PlansIdDelete,
},
{
params: [],
name: 'coupons.create',
path: '/coupons/create',
method: ["GET","HEAD"],
types: {} as CouponsCreateGetHead,
},
{
params: [],
name: 'coupons.run',
path: '/coupons',
method: ["POST"],
types: {} as CouponsPost,
},
{
params: [],
name: 'coupons.clear',
path: '/coupons',
method: ["DELETE"],
types: {} as CouponsDelete,
},
] as const;
export const api = {
routes,
Expand Down
25 changes: 25 additions & 0 deletions app/actions/coupons/clear_coupons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Plans from '#enums/plans'
import Plan from '#models/plan'
import db from '@adonisjs/lucid/services/db'

export default class ClearCoupons {
static async handle() {
const plans = await Plan.query().whereNot('id', Plans.FREE)

await db.transaction(async (trx) => {
for (const plan of plans) {
await plan.useTransaction(trx)

plan.couponCode = null
plan.couponDiscountFixed = null
plan.couponDiscountPercent = null
plan.couponStartAt = null
plan.couponEndAt = null
plan.couponDurationId = null
plan.stripeCouponId = null

await plan.save()
}
})
}
}
19 changes: 19 additions & 0 deletions app/actions/coupons/run_coupon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Plan from '#models/plan'
import { couponValidator } from '#validators/coupon'
import db from '@adonisjs/lucid/services/db'
import { Infer } from '@vinejs/vine/types'

type Params = Infer<typeof couponValidator>

export default class RunCoupon {
static async handle({ planIds, ...data }: Params) {
const plans = await Plan.query().whereIn('id', planIds)

await db.transaction(async (trx) => {
for (const plan of plans) {
await plan.useTransaction(trx)
await plan.merge(data).save()
}
})
}
}
47 changes: 47 additions & 0 deletions app/controllers/coupons_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import ClearCoupons from '#actions/coupons/clear_coupons'
import RunCoupon from '#actions/coupons/run_coupon'
import PlanDto from '#dtos/plan'
import Plans from '#enums/plans'
import Plan from '#models/plan'
import { couponValidator } from '#validators/coupon'
import type { HttpContext } from '@adonisjs/core/http'

export default class CouponsController {
async create({ inertia, bouncer }: HttpContext) {
await bouncer.with('CmsPolicy').authorize('adminOnly')

const plans = await Plan.query().whereNot('id', Plans.FREE)

return inertia.render('coupons/form', {
plans: PlanDto.fromArray(plans),
})
}

/**
* Handle form submission for the edit action
*/
async run({ request, response, session, bouncer }: HttpContext) {
await bouncer.with('CmsPolicy').authorize('adminOnly')

const data = await request.validateUsing(couponValidator)

await RunCoupon.handle(data)

session.flash('success', 'Coupon updated successfully')

return response.redirect().toRoute('plans.index')
}

/**
* Delete record
*/
async clear({ response, session, bouncer }: HttpContext) {
await bouncer.with('CmsPolicy').authorize('adminOnly')

await ClearCoupons.handle()

session.flash('success', 'Coupons cleared successfully')

return response.redirect().toRoute('plans.index')
}
}
6 changes: 5 additions & 1 deletion app/enums/coupon_durations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,9 @@ enum CouponDurations {
ONCE = 2,
}

export default CouponDurations
export const CouponDurationDesc: Record<CouponDurations, string> = {
[CouponDurations.FOREVER]: 'Forever',
[CouponDurations.ONCE]: 'Once',
}

export default CouponDurations
25 changes: 25 additions & 0 deletions app/validators/coupon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import CouponDurations from '#enums/coupon_durations'
import vine from '@vinejs/vine'
import { DateTime } from 'luxon'
import { exists } from './helpers/db.js'

export const couponValidator = vine.compile(
vine.object({
couponCode: vine.string().maxLength(100).optional().nullable(),
couponDiscountFixed: vine.number().range([0, 999]).optional().nullable(),
couponDiscountPercent: vine.number().range([0, 999]).optional().nullable(),
couponStartAt: vine
.date()
.optional()
.nullable()
.transform((date) => date && DateTime.fromJSDate(date)),
couponEndAt: vine
.date()
.optional()
.nullable()
.transform((date) => date && DateTime.fromJSDate(date)),
couponDurationId: vine.number().enum(CouponDurations).optional().nullable(),
stripeCouponId: vine.string().optional().nullable(),
planIds: vine.array(vine.number().exists(exists('plans', 'id'))),
})
)
148 changes: 148 additions & 0 deletions inertia/pages/coupons/form.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<script setup lang="ts">
import { router, useForm } from '@inertiajs/vue3'
import { Link } from '@tuyau/inertia/vue'
import { Save, Trash2 } from 'lucide-vue-next'
import { tuyau } from '~/lib/tuyau'
import { toast } from 'vue-sonner'
import { DateTime } from 'luxon'
import CouponDurations, { CouponDurationDesc } from '#enums/coupon_durations'
import { enumKeys } from '~/lib/utils'
import PlanDto from '#dtos/plan'
type Form = {
couponCode: string
couponDiscountFixed: number | null
couponDiscountPercent: number | null
couponStartAt: string
couponEndAt: string
couponDurationId: number
stripeCouponId: string
planIds: number[]
}
defineProps<{ plans: PlanDto[] }>()
const form = useForm<Form>({
couponCode: '',
couponDiscountFixed: null,
couponDiscountPercent: null,
couponStartAt: DateTime.now().toFormat('yyyy-MM-dd'),
couponEndAt: DateTime.now().plus({ weeks: 1 }).toFormat('yyyy-MM-dd'),
couponDurationId: CouponDurations.FOREVER,
stripeCouponId: '',
planIds: [],
})
function togglePlan(plan: PlanDto) {
if (form.planIds.includes(plan.id)) {
form.planIds = form.planIds.filter((id) => id !== plan.id)
} else {
form.planIds.push(plan.id)
}
}
</script>

<template>
<div class="flex justify-between items-center gap-3 mb-3">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem class="hidden md:block">
<BreadcrumbLink as-child>
<Link route="dashboard"> Dashboard </Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator class="hidden md:block" />
<BreadcrumbItem class="hidden md:block">
<BreadcrumbLink as-child>
<Link route="plans.index"> Plans </Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator class="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Run New Coupon</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>

<div class="flex justify-end items-center gap-1.5">
<Button @click="form.post(tuyau.$url('coupons.run'))">
<Save class="w-4 h-4" />
Run Coupon
</Button>
</div>
</div>

<div class="flex gap-10">
<div
class="w-full flex flex-col gap-4 p-3 lg:p-6 bg-white shadow-xl rounded-lg border border-slate-200"
>
<FormInput
label="Coupon Code"
v-model="form.couponCode"
:errors="form.errors.couponCode"
placeholder="Code customer will enter during checkout"
/>

<FormInput
label="Stripe Coupon ID"
v-model="form.stripeCouponId"
:errors="form.errors.stripeCouponId"
placeholder="Id of the Stripe Coupon"
/>

<FormInput
label="Fixed Discount Amount"
v-model="form.couponDiscountFixed"
:errors="form.errors.couponDiscountFixed"
type="number"
placeholder="Cent value of fixed discount (ex: 800 = $8.00)"
/>

<FormInput
label="Percentage Discount Amount"
v-model="form.couponDiscountPercent"
:errors="form.errors.couponDiscountPercent"
type="number"
placeholder="Percent value of percentage discount (ex: 10 = 10%)"
/>

<FormInput
type="select"
label="Coupon Duration"
v-model="form.couponDurationId"
:errors="form.errors.couponDurationId"
>
<SelectItem
v-for="name in enumKeys(CouponDurations)"
:key="name"
:value="CouponDurations[name]"
>
{{ CouponDurationDesc[CouponDurations[name]] }}
</SelectItem>
</FormInput>

<div class="flex items-center gap-4">
<FormInput
type="date"
label="Start Date"
v-model="form.couponStartAt"
:errors="form.errors.couponStartAt"
/>

<FormInput
type="date"
label="End Date"
v-model="form.couponEndAt"
:errors="form.errors.couponEndAt"
/>
</div>

<FormInput type="group" label="Plans Included in Sale" :error="form.errors.planIds">
<Label v-for="plan in plans" :key="plan.id" class="flex items-center gap-2">
<Checkbox :checked="form.planIds.includes(plan.id)" @update:checked="togglePlan(plan)" />
<span>{{ plan.name }}</span>
</Label>
</FormInput>
</div>
</div>
</template>
Loading

0 comments on commit 1231adf

Please sign in to comment.