Skip to main content

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:
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.

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:

Form Fields

<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'
}
// Example: 10% off subscription orders
{
  discountType: DiscountType.PERCENTAGE,
  discount: 10
}

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

  1. Merchant Code: Use descriptive internal codes for easy identification
  2. Plan Names: Keep customer-facing names clear and concise
  3. Discount Limits: Consider setting maximum discount percentages
  4. Delivery Frequencies: Offer 2-4 options per plan for simplicity
  5. 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

Build docs developers (and LLMs) love