Admin Actions Extension
The admin actions extension provides merchants with tools to create and manage subscription selling plan groups directly from product and variant pages in the Shopify admin. This extension simplifies subscription configuration by providing an intuitive interface for setting up pricing, discounts, and delivery schedules.
Extension Configuration
extensions/admin-subs-action/shopify.extension.toml
api_version = "unstable"
[[ extensions ]]
name = "t:name"
handle = "admin-subs-action"
type = "ui_extension"
[ extensions . capabilities ]
api_access = true
network_access = true
[[ extensions . targeting ]]
module = "./src/ProductExtension.tsx"
target = "admin.product-purchase-option.action.render"
[[ extensions . targeting ]]
module = "./src/ProductVariantExtension.tsx"
target = "admin.product-variant-purchase-option.action.render"
This extension uses API version unstable to access early-access features for purchase options management.
Extension Targets
The extension appears in two locations within Shopify admin:
Product Level
Variant Level
Target : admin.product-purchase-option.action.renderAppears on product detail pages, allowing merchants to create subscription plans that apply to all variants of a product.
Target : admin.product-variant-purchase-option.action.renderAppears on variant detail pages, enabling variant-specific subscription configurations.
Core Component
The main extension component handles both creation and editing of subscription plans:
extensions/admin-subs-action/src/PurchaseOptionsActionExtension.tsx
export default function PurchaseOptionsActionExtension () {
const extensionTarget = useExtensionTarget ();
const { i18n , close , data } = useExtensionApi ({ extensionTarget });
const { getShopInfo } = useShop ();
const { createSellingPlanGroup , graphqlLoading : createLoading } =
useCreateSellingPlanGroup ();
const { updateSellingPlanGroup , graphqlLoading : updateLoading } =
useUpdateSellingPlanGroup ();
const { selected } = data ;
const sellingPlanGroupId = selected [ 0 ][ 'sellingPlanId' ];
const { sellingPlanGroup , loading } = useSellingPlanGroupDetails ({ id: sellingPlanGroupId });
// State management
const [ planName , setPlanName ] = useState ( '' );
const [ merchantCode , setMerchantCode ] = useState ( '' );
const [ offerDiscount , setOfferDiscount ] = useState ( true );
const [ discountType , setDiscountType ] = useState < DiscountTypeType >( DiscountType . PERCENTAGE );
const [ deliveryOptions , setDeliveryOptions ] = useState < DeliveryOption []>([
new DeliveryOption ( 1 , DeliveryInterval . WEEK , 0 , discountType , i18n ),
]);
async function handleSave () {
const resources = extensionTarget === EXTENSION_TARGET_PRODUCT
? { productIds: [ resourceId ]}
: { productVariantIds: [ resourceId ]};
const shopInfo = await getShopInfo ();
const currencyCode = shopInfo ?. currencyCode ?? 'USD' ;
// Validation
const validationResult = validator . safeParse (
{ merchantCode , planName , deliveryOptions }
);
if ( ! validationResult . success ) {
setIssues ( error . issues );
return ;
}
// Create or update selling plan group
if ( sellingPlanGroupId ) {
await updateSellingPlanGroup ({ id: sellingPlanGroupId , input: { ... }});
} else {
await createSellingPlanGroup ({ input: { ... }, resources });
}
close ();
}
return (
< AdminAction
primaryAction = { < Button onPress = { handleSave } > Save </ Button > }
secondaryAction = { < Button onPress = { close } > Cancel </ Button > }
>
{ /* Form fields */ }
</ AdminAction >
);
}
UI Components
The extension uses Admin UI Extensions components:
< BlockStack gap = "base" >
< TextField
name = "planName"
label = { i18n . translate ( 'planName.label' ) }
helpText = { i18n . translate ( 'planName.helpText' ) }
placeholder = { i18n . translate ( 'planName.placeholder' ) }
value = { planName }
onChange = { setPlanName }
error = { issues . find (( issue ) => issue . path [ 0 ] === 'planName' )?. message }
/>
< TextField
name = "merchantCode"
label = { i18n . translate ( 'merchantCode.label' ) }
helpText = { i18n . translate ( 'merchantCode.helpText' ) }
value = { merchantCode }
onChange = { setMerchantCode }
/>
< Checkbox
checked = { offerDiscount }
onChange = { setOfferDiscount }
>
{ i18n . translate ( 'offerDiscount' ) }
</ Checkbox >
{ offerDiscount && (
< ChoiceList
name = "discountType"
choices = { [
{ label: i18n . translate ( 'discountType.percentageOff' ), id: DiscountType . PERCENTAGE },
{ label: i18n . translate ( 'discountType.amountOff' ), id: DiscountType . AMOUNT },
{ label: i18n . translate ( 'discountType.fixedPrice' ), id: DiscountType . PRICE },
] }
value = { discountType }
onChange = { setDiscountType }
/>
) }
</ BlockStack >
Delivery Options
Dynamic delivery option configuration:
extensions/admin-subs-action/src/DeliveryOptionItem.tsx
< BlockStack gap >
{ deliveryOptions . map (( option , index ) => (
< DeliveryOptionItem
key = { index }
index = { index }
option = { option }
offerDiscount = { offerDiscount }
discountType = { discountType }
updateDeliveryOption = { ( field , value ) =>
updateDeliveryOption ( index , field , value )
}
removeDeliveryOption = {
deliveryOptions . length === 1
? undefined
: () => removeDeliveryOption ( index )
}
/>
)) }
</ BlockStack >
< Button onPress = { addDeliveryOption } >
< InlineStack blockAlignment = "center" gap = "small" >
< Icon name = "CirclePlusMinor" />
{ i18n . translate ( 'addOptionButton.label' ) }
</ InlineStack >
</ Button >
Features
Plan Configuration Set plan name and internal merchant code for organization
Discount Options Configure percentage off, amount off, or fixed price discounts
Delivery Intervals Multiple delivery frequencies (days, weeks, months, years)
Multi-Option Plans Create multiple delivery options within a single plan
Discount Types
The extension supports three discount types:
extensions/admin-subs-action/src/consts.ts
export enum DiscountType {
PERCENTAGE = 'PERCENTAGE' ,
AMOUNT = 'FIXED_AMOUNT' ,
PRICE = 'PRICE' ,
NONE = 'NONE'
}
export enum DeliveryInterval {
DAY = 'DAY' ,
WEEK = 'WEEK' ,
MONTH = 'MONTH' ,
YEAR = 'YEAR'
}
Percentage Off
Amount Off
Fixed Price
// Example: 10% off subscription orders
{
discountType : DiscountType . PERCENTAGE ,
discount : 10
}
// Example: $5 off subscription orders
{
discountType : DiscountType . AMOUNT ,
discount : 5.00
}
// Example: Fixed price of $29.99
{
discountType : DiscountType . PRICE ,
discount : 29.99
}
Delivery Option Model
Delivery options are managed through a model class:
extensions/admin-subs-action/src/models/DeliveryOption.ts
export class DeliveryOption {
count : number ;
interval : DeliveryIntervalType ;
discount : number | undefined ;
discountType : DiscountTypeType ;
sellingPlanId ?: string ;
constructor (
count : number ,
interval : DeliveryIntervalType ,
discount : number | undefined ,
discountType : DiscountTypeType ,
i18n : any
) {
this . count = count ;
this . interval = interval ;
this . discount = discount ;
this . discountType = discountType ;
}
toSellingPlanInput ( currencyCode : string ) {
const billingPolicy = {
interval: this . interval ,
intervalCount: this . count ,
};
const deliveryPolicy = {
interval: this . interval ,
intervalCount: this . count ,
};
const pricingPolicies = this . discountType !== DiscountType . NONE
? [{
adjustmentType: this . discountType ,
adjustmentValue: {
percentage: this . discountType === DiscountType . PERCENTAGE
? this . discount
: undefined ,
fixedValue: this . discountType === DiscountType . AMOUNT
? { amount: this . discount , currencyCode }
: undefined ,
price: this . discountType === DiscountType . PRICE
? { amount: this . discount , currencyCode }
: undefined ,
},
}]
: [];
return {
name: this . getDisplayName (),
billingPolicy ,
deliveryPolicy ,
pricingPolicies ,
};
}
static fromSellingPlan ( plan : SellingPlan , i18n : any ) : DeliveryOption {
const interval = plan . billingPolicy . interval ;
const count = plan . billingPolicy . intervalCount ;
const pricing = plan . pricingPolicies ?.[ 0 ];
return new DeliveryOption (
count ,
interval ,
pricing ?. adjustmentValue ?. percentage ||
pricing ?. adjustmentValue ?. fixedValue ?. amount ||
pricing ?. adjustmentValue ?. price ?. amount ,
pricing ?. adjustmentType || DiscountType . NONE ,
i18n
);
}
}
GraphQL Operations
Create Selling Plan Group
extensions/admin-subs-action/src/graphql/ExtensionCreateSellingPlanGroupMutation.ts
mutation CreateSellingPlanGroup (
$input : SellingPlanGroupInput !
$resources : SellingPlanGroupResourceInput !
) {
sellingPlanGroupCreate (
input : $input
resources : $resources
) {
sellingPlanGroup {
id
name
merchantCode
sellingPlans ( first : 10 ) {
edges {
node {
id
name
billingPolicy {
interval
intervalCount
}
pricingPolicies {
adjustmentType
adjustmentValue {
... on SellingPlanFixedPricingPolicy {
adjustmentType
}
}
}
}
}
}
}
userErrors {
field
message
}
}
}
Update Selling Plan Group
extensions/admin-subs-action/src/graphql/ExtensionUpdateSellingPlanGroupMutation.ts
mutation UpdateSellingPlanGroup (
$id : ID !
$input : SellingPlanGroupInput !
) {
sellingPlanGroupUpdate (
id : $id
input : $input
) {
sellingPlanGroup {
id
name
merchantCode
}
userErrors {
field
message
}
}
}
Validation
The extension includes comprehensive validation:
extensions/admin-subs-action/src/validator.ts
import { z } from 'zod' ;
export const getExtensionSellingPlanGroupValidator = ( translate : Function ) => {
return z . object ({
planName: z . string ()
. min ( 1 , { message: translate ( 'validator.nameEmptyError' )}),
merchantCode: z . string ()
. min ( 1 , { message: translate ( 'validator.merchantCodeEmptyError' )}),
deliveryOptions: z . array ( z . object ({
count: z . number ()
. min ( 1 , { message: translate ( 'validator.deliveryFrequencyError' )})
. max ( 36500 , { message: translate ( 'validator.deliveryFrequencyMaxError' )})
. int ({ message: translate ( 'validator.deliveryFrequencyFloatError' )}),
discount: z . number ()
. min ( 0 , { message: translate ( 'validator.discountValueMinError' )})
. optional (),
}))
. refine (( options ) => {
// Check for duplicate delivery frequencies
const frequencies = options . map ( o => ` ${ o . count } - ${ o . interval } ` );
return frequencies . length === new Set ( frequencies ). size ;
}, { message: translate ( 'validator.duplicateDeliveryFrequencyError' )}),
});
};
Localization
The extension supports multiple languages:
extensions/admin-subs-action/locales/en.default.json
{
"name" : "Subscriptions" ,
"planName" : {
"label" : "Title" ,
"placeholder" : "Subscribe and save" ,
"helpText" : "Customers will see this on storefront product pages"
},
"merchantCode" : {
"label" : "Internal description" ,
"helpText" : "For your reference only"
},
"offerDiscount" : "Offer discount" ,
"discountType" : {
"percentageOff" : "Percentage off" ,
"amountOff" : "Amount off" ,
"fixedPrice" : "Fixed price"
},
"deliveryInterval" : {
"week" : {
"singular" : "Deliver every week" ,
"plural" : "Deliver every {{count}} weeks"
},
"month" : {
"singular" : "Deliver every month" ,
"plural" : "Deliver every {{count}} months"
}
}
}
Add translations in the locales/ directory. The extension includes translations for 20+ languages.
Error Handling
The extension displays validation and API errors:
{ errors . length > 0 && (
< Banner tone = "critical" >
< BlockStack gap = "small" >
{ errors . map (( error , index ) => (
< Box key = { index } > { error . message } </ Box >
)) }
</ BlockStack >
</ Banner >
)}
Customization Examples
Adding Custom Intervals
Extend the delivery interval enum:
export enum DeliveryInterval {
DAY = 'DAY' ,
WEEK = 'WEEK' ,
MONTH = 'MONTH' ,
YEAR = 'YEAR' ,
// Add custom intervals
QUARTER = 'MONTH' , // 3 months
}
Custom Validation Rules
Add business-specific validation:
const customValidator = z . object ({
deliveryOptions: z . array ( z . object ({
discount: z . number ()
. max ( 50 , { message: 'Discount cannot exceed 50%' }), // Custom rule
}))
});
Testing
The extension includes comprehensive tests:
extensions/admin-subs-action/src/tests/admin-subs-action.test.tsx
import { render , screen } from '@testing-library/react' ;
import PurchaseOptionsActionExtension from '../PurchaseOptionsActionExtension' ;
describe ( 'PurchaseOptionsActionExtension' , () => {
it ( 'renders plan configuration form' , () => {
render ( < PurchaseOptionsActionExtension /> );
expect ( screen . getByLabelText ( 'Title' )). toBeInTheDocument ();
expect ( screen . getByLabelText ( 'Internal description' )). toBeInTheDocument ();
});
it ( 'validates required fields' , async () => {
// Test validation logic
});
});
Best Practices
Merchant Code : Use descriptive internal codes for easy identification
Plan Names : Keep customer-facing names clear and concise
Discount Limits : Consider setting maximum discount percentages
Delivery Frequencies : Offer 2-4 options per plan for simplicity
Currency Handling : Always pass shop currency code to pricing policies
Subscription Admin Link Deep links from admin to subscription contract management
Theme Extension Displays created plans on storefront product pages