feat: promotion usage limit (#13760)

* feat: promotion usage limit

* fix: update, refactor tests, parallel case

* fix: batch update, cleanup unused map

* feat: paralel campaign and promotion tests

* chore: changesets, fix i18 schema

* fix: ui tweaks

* chore: refactor

---------

Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Frane Polić
2025-11-30 19:43:36 +01:00
committed by GitHub
parent 5da51064d7
commit 536a3f802c
18 changed files with 1269 additions and 11 deletions

View File

@@ -7683,6 +7683,12 @@
"taxInclusive": {
"type": "string"
},
"usageLimit": {
"type": "string"
},
"usage": {
"type": "string"
},
"amount": {
"type": "object",
"properties": {
@@ -7784,6 +7790,8 @@
"addCondition",
"clearAll",
"taxInclusive",
"usageLimit",
"usage",
"amount",
"conditions"
],
@@ -8230,6 +8238,19 @@
},
"required": ["fixed", "percentage"],
"additionalProperties": false
},
"limit": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["title", "description"],
"additionalProperties": false
}
},
"required": [
@@ -8245,7 +8266,8 @@
"allocation",
"code",
"value",
"value_type"
"value_type",
"limit"
],
"additionalProperties": false
},

View File

@@ -2051,6 +2051,8 @@
"addCondition": "Add condition",
"clearAll": "Clear all",
"taxInclusive": "Tax Inclusive",
"usageLimit": "Usage Limit",
"usage": "Usage",
"amount": {
"tooltip": "Select the currency code to enable setting the amount"
},
@@ -2212,6 +2214,10 @@
"title": "Percentage",
"description": "The percentage to discount off the amount. eg. 8%"
}
},
"limit": {
"title": "Usage Limit",
"description": "Maximum number of times this promotion can be used across all orders. Leave empty for unlimited usage."
}
},
"deleteWarning": "You are about to delete the promotion {{code}}. This action cannot be undone.",

View File

@@ -58,6 +58,7 @@ const defaultValues = {
status: "draft" as PromotionStatusValues,
rules: [],
is_tax_inclusive: false,
limit: undefined,
application_method: {
allocation: "each" as ApplicationMethodAllocationValues,
type: "fixed" as ApplicationMethodTypeValues,
@@ -901,7 +902,9 @@ export const CreatePromotionForm = () => {
return (
<Form.Item>
<Form.Label
tooltip={t("promotions.fields.allocationTooltip")}
tooltip={t(
"promotions.fields.allocationTooltip"
)}
>
{t("promotions.fields.allocation")}
</Form.Label>
@@ -987,6 +990,42 @@ export const CreatePromotionForm = () => {
/>
</>
)}
<Divider />
<Form.Field
control={form.control}
name="limit"
render={({ field: { onChange, value, ...field } }) => {
return (
<Form.Item className="basis-1/2">
<Form.Label>
{t("promotions.form.limit.title")}
</Form.Label>
<Form.Control>
<Input
{...field}
type="number"
min={1}
value={value ?? ""}
onChange={(e) => {
const val = e.target.value
onChange(val === "" ? null : parseInt(val, 10))
}}
placeholder="100"
/>
</Form.Control>
<Text
size="small"
leading="compact"
className="text-ui-fg-subtle"
>
{t("promotions.form.limit.description")}
</Text>
<Form.ErrorMessage />
</Form.Item>
)
}}
/>
</div>
</div>
</ProgressTabs.Content>

View File

@@ -28,6 +28,7 @@ export const CreatePromotionSchema = z
status: z.enum(["draft", "active", "inactive"]),
rules: RuleSchema,
is_tax_inclusive: z.boolean().optional(),
limit: z.number().int().min(1).nullable().optional(),
application_method: z.object({
allocation: z.enum(["each", "across", "once"]),
value: z.number().min(0).or(z.string().min(1)),

View File

@@ -196,6 +196,20 @@ export const PromotionGeneralSection = ({
</div>
</div>
)}
{typeof promotion.limit === "number" && (
<div className="text-ui-fg-subtle grid grid-cols-2 items-start px-6 py-4">
<Text size="small" weight="plus" leading="compact">
Usage Limit
</Text>
<div className="flex items-center gap-x-2">
<Text className="inline" size="small" leading="compact">
{promotion.used || 0} / {promotion.limit}
</Text>
</div>
</div>
)}
</Container>
)
}