feat: Add SchemeForm component for creating and editing schemes with validation.

This commit is contained in:
2026-10-22 16:36:00 +05:30
parent 3a503abe6a
commit 58247a1a0b
8 changed files with 740 additions and 379 deletions
@@ -0,0 +1,30 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { toast } from '@/components/ui/toast'
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
import type { SchemeResponse } from '@/types/api'
import { SchemeForm } from '../../_components/scheme-form'
export default function EditSchemePage() {
const router = useRouter()
const { id } = useParams<{ id: string }>()
const [scheme, setScheme] = useState<SchemeResponse | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
schemesApi
.get(id)
.then(setScheme)
.catch((err) => {
toast.add({ title: 'Failed to load scheme', description: getApiError(err), type: 'error' })
router.push('/pricing/schemes')
})
.finally(() => setLoading(false))
}, [id, router])
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
if (!scheme) return <p className="text-sm text-muted-foreground">Scheme not found.</p>
return <SchemeForm scheme={scheme} />
}
@@ -0,0 +1,30 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { toast } from '@/components/ui/toast'
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
import type { SchemeResponse } from '@/types/api'
import { SchemeForm } from '../_components/scheme-form'
export default function SchemeDetailsPage() {
const router = useRouter()
const { id } = useParams<{ id: string }>()
const [scheme, setScheme] = useState<SchemeResponse | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
schemesApi
.get(id)
.then(setScheme)
.catch((err) => {
toast.add({ title: 'Failed to load scheme', description: getApiError(err), type: 'error' })
router.push('/pricing/schemes')
})
.finally(() => setLoading(false))
}, [id, router])
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
if (!scheme) return <p className="text-sm text-muted-foreground">Scheme not found.</p>
return <SchemeForm scheme={scheme} readOnly />
}
@@ -0,0 +1,602 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useFieldArray, useWatch } from 'react-hook-form'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, ArrowLeft01Icon, Delete02Icon } from '@hugeicons/core-free-icons'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import {
schemes as schemesApi,
regions as regionsApi,
items as itemsApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { SchemeResponse, RegionResponse, ItemSummaryResponse, SchemeRuleType } from '@/types/api'
import { RULE_META, parseRuleConfig } from './scheme-labels'
const RULE_ORDER: SchemeRuleType[] = ['BuyXGetY', 'FreeBundle', 'FlatPercent', 'SlabDiscount']
const schema = z
.object({
name: z.string(),
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
regionId: z.string().optional(),
eligibleSegment: z.string().optional(),
dateRangeFrom: z.string().min(1, 'Required'),
dateRangeTo: z.string().min(1, 'Required'),
isActive: z.boolean(),
// Buy & get free
triggerItemId: z.string(),
triggerQty: z.number(),
// Buy & get free + combo gift: what the store receives
freeItemId: z.string(),
freeQty: z.number(),
// Combo gift: the products that must be bought together
bundle: z.array(z.object({ itemId: z.string(), qty: z.number() })),
// Flat discount
discountPercent: z.number(),
// Spend more, save more
tiers: z.array(z.object({ minOrderAmount: z.number(), discountPercent: z.number() })),
})
.superRefine((v, ctx) => {
const fail = (path: (string | number)[], message: string) => ctx.addIssue({ code: 'custom', path, message })
const wholeQty = (n: number) => Number.isInteger(n) && n >= 1
const pct = (n: number) => n > 0 && n <= 100
if (v.dateRangeFrom && v.dateRangeTo && v.dateRangeFrom > v.dateRangeTo)
fail(['dateRangeTo'], 'End date must not be before the start date')
if (v.ruleType === 'BuyXGetY') {
if (!v.triggerItemId) fail(['triggerItemId'], 'Choose the product the store must buy')
if (!wholeQty(v.triggerQty)) fail(['triggerQty'], 'Enter a whole number, 1 or more')
}
if (v.ruleType === 'BuyXGetY' || v.ruleType === 'FreeBundle') {
if (!v.freeItemId) fail(['freeItemId'], 'Choose the free product')
if (!wholeQty(v.freeQty)) fail(['freeQty'], 'Enter a whole number, 1 or more')
}
if (v.ruleType === 'FreeBundle') {
if (v.bundle.length === 0) fail(['bundle'], 'Add at least one product')
v.bundle.forEach((b, i) => {
if (!b.itemId) fail(['bundle', i, 'itemId'], 'Choose a product')
if (!wholeQty(b.qty)) fail(['bundle', i, 'qty'], 'Whole number, 1 or more')
})
const ids = v.bundle.map((b) => b.itemId).filter(Boolean)
if (new Set(ids).size !== ids.length) fail(['bundle'], 'Each product can only be listed once')
}
if (v.ruleType === 'FlatPercent' && !pct(v.discountPercent))
fail(['discountPercent'], 'Enter a percentage between 1 and 100')
if (v.ruleType === 'SlabDiscount') {
if (v.tiers.length === 0) fail(['tiers'], 'Add at least one level')
v.tiers.forEach((t, i) => {
if (!(t.minOrderAmount > 0)) fail(['tiers', i, 'minOrderAmount'], 'Enter an amount above 0')
if (!pct(t.discountPercent)) fail(['tiers', i, 'discountPercent'], '1 to 100')
})
}
})
type FormValues = z.infer<typeof schema>
const EMPTY: FormValues = {
name: '',
ruleType: 'BuyXGetY',
regionId: '',
eligibleSegment: '',
dateRangeFrom: '',
dateRangeTo: '',
isActive: true,
triggerItemId: '',
triggerQty: 10,
freeItemId: '',
freeQty: 1,
bundle: [{ itemId: '', qty: 1 }],
discountPercent: 10,
tiers: [{ minOrderAmount: 5000, discountPercent: 5 }],
}
function toFormValues(scheme: SchemeResponse): FormValues {
const cfg = parseRuleConfig(scheme)
return {
...EMPTY,
name: scheme.name,
ruleType: scheme.ruleType,
regionId: scheme.regionId ?? '',
eligibleSegment: scheme.eligibleSegment ?? '',
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
dateRangeTo: scheme.dateRangeTo.split('T')[0],
isActive: scheme.isActive,
triggerItemId: scheme.triggerItemId ?? '',
triggerQty: scheme.triggerQty ?? EMPTY.triggerQty,
freeItemId: scheme.freeItemId ?? '',
freeQty: scheme.freeQty ?? EMPTY.freeQty,
bundle: scheme.bundleComponents.length
? scheme.bundleComponents.map((c) => ({ itemId: c.itemId, qty: c.qty }))
: EMPTY.bundle,
discountPercent: cfg.discountPercent || EMPTY.discountPercent,
tiers: cfg.tiers.length ? cfg.tiers : EMPTY.tiers,
}
}
interface SchemeFormProps {
/** null = create a new scheme, otherwise edit this one. */
scheme: SchemeResponse | null
/** Show the scheme exactly as the edit page does, but locked, with an Edit button. */
readOnly?: boolean
}
export function SchemeForm({ scheme, readOnly = false }: SchemeFormProps) {
const router = useRouter()
const goBack = () => router.push('/pricing/schemes')
const isEdit = !!scheme
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const form = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: EMPTY })
const bundle = useFieldArray({ control: form.control, name: 'bundle' })
const tiers = useFieldArray({ control: form.control, name: 'tiers' })
const v = useWatch({ control: form.control }) as FormValues
const ruleType = v.ruleType ?? 'BuyXGetY'
useEffect(() => {
form.reset(scheme ? toFormValues(scheme) : EMPTY)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scheme])
useEffect(() => {
Promise.all([regionsApi.list({ pageSize: 100 }), itemsApi.list({ pageSize: 200 })])
.then(([regions, items]) => {
setRegionsList(getPaginatedItems(regions))
setItemsList(getPaginatedItems(items))
})
.catch(() => toast.add({ title: 'Failed to load regions or products', type: 'error' }))
}, [])
const itemName = (id?: string) => itemsList.find((i) => i.id === id)?.name
// Live sentence so the user can check the offer reads the way they intended.
const preview = useMemo(() => {
const n = (x?: number) => (x && x > 0 ? x : '?')
if (ruleType === 'BuyXGetY') {
const buy = itemName(v.triggerItemId) ?? 'a product'
const free = itemName(v.freeItemId) ?? 'a product'
return `Buy ${n(v.triggerQty)} × ${buy}, get ${n(v.freeQty)} × ${free} free. Repeats: buying ${n(v.triggerQty && v.triggerQty * 2)} earns ${n(v.freeQty && v.freeQty * 2)} free.`
}
if (ruleType === 'FreeBundle') {
const parts = (v.bundle ?? []).map((b) => `${n(b.qty)} × ${itemName(b.itemId) ?? 'a product'}`).join(' + ')
return `Buy ${parts || 'products'} together, get ${n(v.freeQty)} × ${itemName(v.freeItemId) ?? 'a product'} free (once per order).`
}
if (ruleType === 'FlatPercent') return `${n(v.discountPercent)}% off the whole order.`
const levels = [...(v.tiers ?? [])]
.sort((a, b) => a.minOrderAmount - b.minOrderAmount)
.map((t) => `${n(t.discountPercent)}% off from Rs. ${(t.minOrderAmount || 0).toLocaleString()}`)
return levels.length ? `${levels.join('; ')}. The highest level the order reaches applies.` : 'Add at least one level.'
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [v, ruleType, itemsList])
async function onSubmit(values: FormValues) {
const isGoods = values.ruleType === 'BuyXGetY' || values.ruleType === 'FreeBundle'
const ruleConfig =
values.ruleType === 'FlatPercent'
? JSON.stringify({ discountPercent: values.discountPercent })
: values.ruleType === 'SlabDiscount'
? JSON.stringify({ tiers: values.tiers })
: null
// The name is optional: when left blank, describe the offer itself (e.g. "Buy 10 Soap, get 1 Soap free").
const n = (id: string) => itemName(id) ?? 'item'
const autoName =
values.ruleType === 'BuyXGetY'
? `Buy ${values.triggerQty} ${n(values.triggerItemId)}, get ${values.freeQty} ${n(values.freeItemId)} free`
: values.ruleType === 'FreeBundle'
? `Buy ${values.bundle.map((b) => `${b.qty} ${n(b.itemId)}`).join(' + ')}, get ${values.freeQty} ${n(values.freeItemId)} free`
: values.ruleType === 'FlatPercent'
? `${values.discountPercent}% off the order`
: `Up to ${Math.max(...values.tiers.map((t) => t.discountPercent))}% off by order value`
const common = {
name: values.name.trim() || autoName,
ruleConfig,
regionId: values.regionId || null,
eligibleSegment: values.eligibleSegment?.trim() || null,
dateRangeFrom: values.dateRangeFrom,
dateRangeTo: values.dateRangeTo,
isActive: values.isActive,
}
const goods = isGoods
? {
triggerItemId: values.ruleType === 'BuyXGetY' ? values.triggerItemId : null,
triggerQty: values.ruleType === 'BuyXGetY' ? values.triggerQty : undefined,
freeItemId: values.freeItemId,
freeQty: values.freeQty,
}
: {}
try {
if (!scheme) {
await schemesApi.create({
...common,
ruleType: values.ruleType,
...goods,
bundleComponents:
values.ruleType === 'FreeBundle' ? values.bundle.map((b) => ({ itemId: b.itemId, qty: b.qty })) : undefined,
})
toast.add({ title: 'Scheme created', type: 'success' })
} else {
// The rule type is fixed once created; update the basics, then the free-goods rule and bundle rows.
await schemesApi.update(scheme.id, { ...common, agentLimitExempt: scheme.agentLimitExempt })
if (isGoods) {
await schemesApi.setFreeGoodsRule(scheme.id, {
triggerItemId: goods.triggerItemId,
triggerQty: goods.triggerQty,
freeItemId: values.freeItemId,
freeQty: values.freeQty,
})
}
if (values.ruleType === 'FreeBundle') {
const keep = new Set(values.bundle.map((b) => b.itemId))
for (const old of scheme.bundleComponents) {
if (!keep.has(old.itemId)) await schemesApi.removeBundleComponent(scheme.id, old.itemId)
}
for (const b of values.bundle) await schemesApi.upsertBundleComponent(scheme.id, b.itemId, b.qty)
}
toast.add({ title: 'Scheme updated', type: 'success' })
}
goBack()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
const itemSelect = (value: string, onChange: (v: string) => void, placeholder: string, invalid?: boolean) => (
<Select value={value} onValueChange={(x) => onChange(x ?? '')}>
<SelectTrigger aria-invalid={invalid}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{itemsList.map((i) => (
<SelectItem key={i.id} value={i.id}>
{i.name} — {i.sku}
</SelectItem>
))}
</SelectContent>
</Select>
)
const numberInput = (value: number, onChange: (n: number) => void, invalid?: boolean, props?: { min?: number; max?: number; step?: string }) => (
<Input
type="number"
value={Number.isFinite(value) ? value : ''}
onChange={(e) => onChange(Number(e.target.value))}
aria-invalid={invalid}
{...props}
/>
)
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={goBack}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<h1 className="text-2xl font-semibold text-foreground">
{readOnly ? 'Scheme Details' : isEdit ? 'Edit Scheme' : 'Add Scheme'}
</h1>
<p className="text-sm text-muted-foreground">
{readOnly
? 'How this offer is set up.'
: isEdit ? 'Update the offer below.' : 'Pick the kind of offer, then fill in the details.'}
</p>
</div>
{readOnly && scheme && (
<Button type="button" className="ml-auto" onClick={() => router.push(`/pricing/schemes/${scheme.id}/edit`)}>
Edit Scheme
</Button>
)}
</div>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<fieldset disabled={readOnly} className="m-0 min-w-0 rounded-2xl border p-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="Scheme name"
description="Optional. If left blank, the name is created from the offer."
render={({ field, invalid }) => (
<Input
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Soap — buy 10 get 1 free (optional)"
aria-invalid={invalid}
/>
)}
/>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-foreground">What kind of offer is it?</p>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
{RULE_ORDER.map((type) => {
const selected = ruleType === type
return (
<button
key={type}
type="button"
disabled={isEdit && !selected}
onClick={() => form.setValue('ruleType', type, { shouldValidate: false })}
className={`rounded-xl border p-3 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
selected ? 'border-primary bg-primary/5' : 'hover:bg-muted/50'
}`}
>
<p className="text-sm font-medium text-foreground">{RULE_META[type].label}</p>
<p className="text-xs text-muted-foreground">{RULE_META[type].description}</p>
<p className="mt-1 text-xs italic text-muted-foreground">e.g. {RULE_META[type].example}</p>
</button>
)
})}
</div>
{isEdit && <p className="text-xs text-muted-foreground">The kind of offer can&apos;t be changed after it is created.</p>}
</div>
{/* ── Buy & get free ─────────────────────────────────────────── */}
{ruleType === 'BuyXGetY' && (
<div className="flex flex-col gap-4 rounded-xl border p-4">
<p className="text-sm font-medium text-foreground">The store buys</p>
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
<FormField
control={form.control}
name="triggerItemId"
label="Product"
render={({ field, invalid }) =>
itemSelect(field.value as string, (id) => {
field.onChange(id)
if (!form.getValues('freeItemId')) form.setValue('freeItemId', id)
}, 'Select product…', invalid)
}
/>
<FormField
control={form.control}
name="triggerQty"
label="Quantity"
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
/>
</div>
<p className="text-sm font-medium text-foreground">…and gets free</p>
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
<FormField
control={form.control}
name="freeItemId"
label="Free product"
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
/>
<FormField
control={form.control}
name="freeQty"
label="Free quantity"
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
/>
</div>
</div>
)}
{/* ── Combo gift ─────────────────────────────────────────────── */}
{ruleType === 'FreeBundle' && (
<div className="flex flex-col gap-4 rounded-xl border p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-foreground">The store must buy all of these</p>
<Button type="button" variant="outline" size="sm" onClick={() => bundle.append({ itemId: '', qty: 1 })}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add product
</Button>
</div>
{bundle.fields.map((row, index) => (
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_7rem_auto]">
<FormField
control={form.control}
name={`bundle.${index}.itemId`}
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
/>
<FormField
control={form.control}
name={`bundle.${index}.qty`}
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={bundle.fields.length === 1}
onClick={() => bundle.remove(index)}
>
<HugeiconsIcon icon={Delete02Icon} size={16} />
</Button>
</div>
))}
{form.formState.errors.bundle?.message && (
<p className="text-xs text-destructive">{form.formState.errors.bundle.message}</p>
)}
<p className="text-sm font-medium text-foreground">…and gets this free</p>
<div className="grid gap-4 sm:grid-cols-[1fr_8rem]">
<FormField
control={form.control}
name="freeItemId"
label="Free product"
render={({ field, invalid }) => itemSelect(field.value as string, field.onChange, 'Select product…', invalid)}
/>
<FormField
control={form.control}
name="freeQty"
label="Free quantity"
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1 })}
/>
</div>
</div>
)}
{/* ── Flat discount ──────────────────────────────────────────── */}
{ruleType === 'FlatPercent' && (
<div className="rounded-xl border p-4">
<FormField
control={form.control}
name="discountPercent"
label="Discount on the whole order (%)"
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
/>
</div>
)}
{/* ── Spend more, save more ──────────────────────────────────── */}
{ruleType === 'SlabDiscount' && (
<div className="flex flex-col gap-3 rounded-xl border p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-foreground">Discount levels</p>
<Button type="button" variant="outline" size="sm" onClick={() => tiers.append({ minOrderAmount: 0, discountPercent: 0 })}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Add level
</Button>
</div>
{tiers.fields.map((row, index) => (
<div key={row.id} className="grid items-start gap-2 sm:grid-cols-[1fr_1fr_auto]">
<FormField
control={form.control}
name={`tiers.${index}.minOrderAmount`}
label={index === 0 ? 'When the order reaches (Rs.)' : undefined}
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 0 })}
/>
<FormField
control={form.control}
name={`tiers.${index}.discountPercent`}
label={index === 0 ? 'Discount (%)' : undefined}
render={({ field, invalid }) => numberInput(field.value as number, field.onChange, invalid, { min: 1, max: 100, step: '0.5' })}
/>
<Button
type="button"
variant="outline"
size="sm"
className={index === 0 ? 'mt-6' : ''}
disabled={tiers.fields.length === 1}
onClick={() => tiers.remove(index)}
>
<HugeiconsIcon icon={Delete02Icon} size={16} />
</Button>
</div>
))}
{form.formState.errors.tiers?.message && (
<p className="text-xs text-destructive">{form.formState.errors.tiers.message}</p>
)}
</div>
)}
<div className="rounded-xl bg-muted/50 px-4 py-3 text-sm">
<span className="font-medium text-foreground">Summary: </span>
<span className="text-muted-foreground">{preview}</span>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="dateRangeFrom"
label="Starts on"
required
render={({ field, invalid }) => (
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
)}
/>
<FormField
control={form.control}
name="dateRangeTo"
label="Ends on"
description="The offer still applies on this day."
required
render={({ field, invalid }) => (
<Input type="date" name={field.name} ref={field.ref} value={field.value as string}
onChange={field.onChange} onBlur={field.onBlur} aria-invalid={invalid} />
)}
/>
</div>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox id="isActive" checked={field.value as boolean} onCheckedChange={field.onChange} />
<label htmlFor="isActive" className="cursor-pointer text-sm font-medium">
Active
</label>
</div>
)}
/>
<div className="flex flex-col gap-3 rounded-xl border p-4">
<p className="text-sm font-medium text-foreground">Where does it apply? <span className="font-normal text-muted-foreground">(optional)</span></p>
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="regionId"
label="Region"
description="Leave on All Regions to offer it everywhere."
render={({ field }) => (
<Select value={field.value as string} onValueChange={(x) => field.onChange(x ?? '')}>
<SelectTrigger>
<SelectValue placeholder="All Regions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Regions</SelectItem>
{regionsList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="eligibleSegment"
label="Store segment"
description="Optional, e.g. Gold. Empty = every store."
render={({ field }) => (
<Input
name={field.name}
ref={field.ref}
value={(field.value as string) ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="All segments"
/>
)}
/>
</div>
</div>
</FieldGroup>
</fieldset>
{!readOnly && <div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={goBack}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
</Button>
</div>}
</form>
</div>
)
}
@@ -0,0 +1,61 @@
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
// Plain-language names for the four rule types, so users don't need to know the technical names.
export const RULE_META: Record<SchemeRuleType, { label: string; description: string; example: string }> = {
BuyXGetY: {
label: 'Buy & get free',
description: 'The store buys a quantity of a product and gets free units.',
example: 'Buy 10 Soap, get 1 Soap free',
},
FreeBundle: {
label: 'Combo gift',
description: 'The store buys a set of products together and gets a free gift.',
example: 'Buy Soap + Shampoo, get a Toothbrush free',
},
FlatPercent: {
label: 'Flat discount',
description: 'A fixed percentage off the whole order.',
example: '10% off every order',
},
SlabDiscount: {
label: 'Spend more, save more',
description: 'The discount grows as the order value reaches higher levels.',
example: '5% off above Rs. 5,000, 8% off above Rs. 10,000',
},
}
export interface SlabTierConfig {
minOrderAmount: number
discountPercent: number
}
export function parseRuleConfig(scheme: Pick<SchemeResponse, 'ruleType' | 'ruleConfig'>) {
try {
const cfg = scheme.ruleConfig ? JSON.parse(scheme.ruleConfig) : null
if (scheme.ruleType === 'FlatPercent') return { discountPercent: Number(cfg?.discountPercent) || 0, tiers: [] as SlabTierConfig[] }
if (scheme.ruleType === 'SlabDiscount') return { discountPercent: 0, tiers: (cfg?.tiers ?? []) as SlabTierConfig[] }
} catch {
// Malformed config — fall through to empty.
}
return { discountPercent: 0, tiers: [] as SlabTierConfig[] }
}
const money = (n: number) => `Rs. ${n.toLocaleString()}`
/** One-sentence description of what a scheme does, built from its saved rule. */
export function describeScheme(scheme: SchemeResponse): string {
switch (scheme.ruleType) {
case 'BuyXGetY':
return `Buy ${scheme.triggerQty ?? '?'} ${scheme.triggerItemName ?? 'item'}, get ${scheme.freeQty ?? '?'} ${scheme.freeItemName ?? 'item'} free`
case 'FreeBundle': {
const parts = scheme.bundleComponents.map((c) => `${c.qty} ${c.itemName}`).join(' + ') || 'items'
return `Buy ${parts}, get ${scheme.freeQty ?? '?'} ${scheme.freeItemName ?? 'item'} free`
}
case 'FlatPercent':
return `${parseRuleConfig(scheme).discountPercent}% off the order`
case 'SlabDiscount': {
const tiers = [...parseRuleConfig(scheme).tiers].sort((a, b) => a.minOrderAmount - b.minOrderAmount)
return tiers.length ? tiers.map((t) => `${t.discountPercent}% off from ${money(t.minOrderAmount)}`).join(', ') : 'No levels set'
}
}
}
@@ -1,265 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { schemes as schemesApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { SchemeResponse, RegionResponse } from '@/types/api'
const schema = z.object({
name: z.string().min(1, 'Name is required'),
ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']),
regionId: z.string().optional(),
dateRangeFrom: z.string().min(1, 'Required'),
dateRangeTo: z.string().min(1, 'Required'),
isActive: z.boolean(),
})
type FormValues = z.infer<typeof schema>
interface SchemeSheetProps {
open: boolean
onClose: () => void
scheme: SchemeResponse | null
onSuccess: () => void
}
export function SchemeSheet({ open, onClose, scheme, onSuccess }: SchemeSheetProps) {
const isEdit = !!scheme
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
name: '',
ruleType: 'FlatPercent',
regionId: '',
dateRangeFrom: '',
dateRangeTo: '',
isActive: true,
},
})
useEffect(() => {
if (open) {
form.reset(
scheme
? {
name: scheme.name,
ruleType: scheme.ruleType,
regionId: scheme.regionId ?? '',
dateRangeFrom: scheme.dateRangeFrom.split('T')[0],
dateRangeTo: scheme.dateRangeTo.split('T')[0],
isActive: scheme.isActive,
}
: {
name: '',
ruleType: 'FlatPercent',
regionId: '',
dateRangeFrom: '',
dateRangeTo: '',
isActive: true,
}
)
}
}, [open, scheme])
useEffect(() => {
if (open) {
regionsApi.list({ pageSize: 100 })
.then((res) => setRegionsList(getPaginatedItems(res)))
.catch(() => {
toast.add({ title: 'Failed to load regions', type: 'error' })
})
}
}, [open])
async function onSubmit(values: FormValues) {
try {
const body = {
name: values.name,
ruleType: values.ruleType,
regionId: values.regionId || null,
dateRangeFrom: values.dateRangeFrom,
dateRangeTo: values.dateRangeTo,
isActive: values.isActive,
}
if (isEdit) {
await schemesApi.update(scheme!.id, body)
toast.add({ title: 'Scheme updated', type: 'success' })
} else {
await schemesApi.create(body)
toast.add({ title: 'Scheme created', type: 'success' })
}
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit Scheme' : 'Add Scheme'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the scheme details below.' : 'Create a new promotion or discount scheme.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="name"
label="Name"
required
render={({ field, invalid }) => (
<Input
id="name"
name={field.name}
ref={field.ref}
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. Buy 2 Get 1 Free"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="ruleType"
label="Rule Type"
required
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="Select rule type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="BuyXGetY">BuyXGetY</SelectItem>
<SelectItem value="SlabDiscount">SlabDiscount</SelectItem>
<SelectItem value="FlatPercent">FlatPercent</SelectItem>
<SelectItem value="FreeBundle">FreeBundle</SelectItem>
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="regionId"
label="Region"
render={({ field }) => (
<Select
value={field.value as string}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="All Regions" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Regions</SelectItem>
{regionsList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="dateRangeFrom"
label="Date Range From"
required
render={({ field, invalid }) => (
<Input
id="dateRangeFrom"
name={field.name}
ref={field.ref}
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="dateRangeTo"
label="Date Range To"
required
render={({ field, invalid }) => (
<Input
id="dateRangeTo"
name={field.name}
ref={field.ref}
type="date"
value={field.value as string}
onChange={field.onChange}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<div className="flex items-center gap-2">
<Checkbox
id="isActive"
checked={field.value as boolean}
onCheckedChange={field.onChange}
/>
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
Active
</label>
</div>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -1,87 +0,0 @@
'use client'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { SchemeResponse } from '@/types/api'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
interface SchemeViewSheetProps {
open: boolean
onClose: () => void
scheme: SchemeResponse | null
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
)
}
export function SchemeViewSheet({ open, onClose, scheme }: SchemeViewSheetProps) {
if (!scheme) return null
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Scheme Details</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<Row label="Name" value={scheme.name} />
<Row label="Rule Type" value={<Badge>{scheme.ruleType}</Badge>} />
<Row label="Region" value={scheme.regionName ?? 'All Regions'} />
<Row label="Eligible Segment" value={scheme.eligibleSegment ?? 'All Segments'} />
<Row label="Status" value={<Badge variant={scheme.isActive ? 'default' : 'secondary'}>{scheme.isActive ? 'Active' : 'Inactive'}</Badge>} />
<Row label="Date Range" value={`${fmtDate(scheme.dateRangeFrom)} → ${fmtDate(scheme.dateRangeTo)}`} />
<Row label="Agent Limit Exempt" value={scheme.agentLimitExempt ? 'Yes' : 'No'} />
{scheme.baselineExpectedSales > 0 && (
<Row label="Baseline Expected Sales" value={`Rs. ${scheme.baselineExpectedSales.toLocaleString()}`} />
)}
{scheme.ruleConfig && <Row label="Rule Config" value={<span className="font-mono text-xs">{scheme.ruleConfig}</span>} />}
{scheme.triggerItemName && (
<Row label="Trigger" value={`${scheme.triggerQty} × ${scheme.triggerItemName}`} />
)}
{scheme.freeItemName && (
<Row label="Free Goods" value={`${scheme.freeQty} × ${scheme.freeItemName}`} />
)}
<Row label="Created" value={fmtDate(scheme.createdAt)} />
</div>
{scheme.bundleComponents.length > 0 && (
<div>
<p className="mb-2 text-sm font-medium text-foreground">Bundle Components</p>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="px-3 py-2 text-right font-medium">Qty</th>
</tr>
</thead>
<tbody>
{scheme.bundleComponents.map((c) => (
<tr key={c.itemId} className="border-t">
<td className="px-3 py-2">{c.itemName} <span className="ml-1 text-xs text-muted-foreground">({c.sku})</span></td>
<td className="px-3 py-2 text-right">{c.qty}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -12,8 +13,7 @@ import { toast } from '@/components/ui/toast'
import { schemes as schemesApi, getApiError } from '@/lib/api/index'
import type { SchemeResponse, SchemeRuleType } from '@/types/api'
import { SchemeSheet } from './scheme-sheet'
import { SchemeViewSheet } from './scheme-view-sheet'
import { RULE_META, describeScheme } from './scheme-labels'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -26,14 +26,11 @@ const RULE_TYPE_VARIANT: Record<SchemeRuleType, 'default' | 'secondary'> = {
}
export function SchemesClient() {
const router = useRouter()
const [data, setData] = useState<SchemeResponse[]>([])
const [loading, setLoading] = useState(true)
const [pageSize, setPageSize] = useState(20)
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<SchemeResponse | null>(null)
const [viewing, setViewing] = useState<SchemeResponse | null>(null)
const [viewOpen, setViewOpen] = useState(false)
const fetchData = useCallback(async () => {
setLoading(true)
@@ -52,18 +49,15 @@ export function SchemesClient() {
}, [fetchData])
function openCreate() {
setEditing(null)
setSheetOpen(true)
router.push('/pricing/schemes/new')
}
function openEdit(scheme: SchemeResponse) {
setEditing(scheme)
setSheetOpen(true)
router.push(`/pricing/schemes/${scheme.id}/edit`)
}
function openView(scheme: SchemeResponse) {
setViewing(scheme)
setViewOpen(true)
router.push(`/pricing/schemes/${scheme.id}`)
}
async function handleDelete(scheme: SchemeResponse) {
@@ -91,11 +85,14 @@ export function SchemesClient() {
},
{
key: 'ruleType',
title: 'Rule Type',
title: 'Offer',
fields: ['ruleType'],
width: 160,
minWidth: 280,
cellRender: (row) => (
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]}>{row.ruleType}</Badge>
<div className="flex flex-col gap-1">
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]} className="w-fit">{RULE_META[row.ruleType].label}</Badge>
<span className="text-sm text-muted-foreground">{describeScheme(row)}</span>
</div>
),
},
{
@@ -177,18 +174,6 @@ export function SchemesClient() {
}}
/>
<SchemeSheet
open={sheetOpen}
onClose={() => setSheetOpen(false)}
scheme={editing}
onSuccess={fetchData}
/>
<SchemeViewSheet
open={viewOpen}
onClose={() => setViewOpen(false)}
scheme={viewing}
/>
</>
)
}
@@ -0,0 +1,5 @@
import { SchemeForm } from '../_components/scheme-form'
export default function NewSchemePage() {
return <SchemeForm scheme={null} />
}