From a0e9c6fbad99ca37cd24373bd542fc4a6741cd5f Mon Sep 17 00:00:00 2001 From: Xaver Date: Sun, 28 Jun 2026 11:43:45 +0200 Subject: [PATCH 1/3] feat: add discounted price mapping field Adds a "Discounted price" mapping option for paragraph, heading, and button blocks that applies the scope coupon discount to the current plan price. Fetches coupon metadata from the Freemius API (`products/{id}/coupons.json?code=...`) in the block editor via a new `useCoupon` hook, and embeds coupon discount data in page output for frontend modifier updates. Shared JS/PHP helpers apply percentage or fixed-amount discounts (including multi-currency `discounts`). When no coupon is configured in scope, the field shows an editor error and renders empty on the frontend. Main files: `src/hooks/useMapping.js`, `src/scope/MappingSettings.js`, `src/blocks/modifier/view.js`, `includes/class-freemius-scope.php`, new `src/util/discountedPrice.js`. Site owners can now display coupon-adjusted prices alongside regular pricing blocks without duplicating amounts manually. --- CHANGELOG.md | 2 + docs/README.md | 3 +- includes/class-freemius-api.php | 5 + includes/class-freemius-coupon.php | 113 +++++++++++++++++++++ includes/class-freemius-scope.php | 31 +++++- includes/dummy-response.php | 2 + src/blocks/modifier/view.js | 55 ++++++++--- src/hooks/index.js | 2 + src/hooks/useCoupon.js | 43 ++++++++ src/hooks/useMapping.js | 151 +++++++++++++++++++++-------- src/scope/MappingSettings.js | 7 +- src/util/discountedPrice.js | 90 +++++++++++++++++ src/util/index.js | 7 ++ tests/php/CouponTest.php | 110 +++++++++++++++++++++ tests/php/Freemius_TestCase.php | 5 + tests/php/ScopeTest.php | 48 +++++++++ 16 files changed, 614 insertions(+), 60 deletions(-) create mode 100644 includes/class-freemius-coupon.php create mode 100644 src/hooks/useCoupon.js create mode 100644 src/util/discountedPrice.js create mode 100644 tests/php/CouponTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 79abd1d..d238804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to Freemius for WordPress are documented in this file. ## [Unreleased] +- added: "Discounted price" mapping field that applies the scope coupon discount to the displayed plan price + ## [0.4.2] - fixed: missing import for MappingSettings diff --git a/docs/README.md b/docs/README.md index 034e1c7..32f3572 100644 --- a/docs/README.md +++ b/docs/README.md @@ -64,11 +64,12 @@ The following blocks can "receive" data from the scope: - HeadingBlock - Button Block -Currently, 5 fields are supported: +Currently, 6 fields are supported: - Title - Description - Price +- Discounted price (requires a coupon code in scope settings) - Licenses (1, 2, 3, Unlimited) - Billing Cycle (Monthly, Yearly, Lifetime) diff --git a/includes/class-freemius-api.php b/includes/class-freemius-api.php index 94dda28..0804df9 100644 --- a/includes/class-freemius-api.php +++ b/includes/class-freemius-api.php @@ -479,6 +479,10 @@ private function get_dummy_response( string $endpoint ) { $body = $dummy['pricing']; } + if ( substr( $endpoint, -strlen( '/coupons.json' ) ) === '/coupons.json' ) { + $body = $dummy['coupons']; + } + if ( substr( $endpoint, -strlen( '/products/19794.json' ) ) === '/products/19794.json' ) { $body = $dummy['product']; } @@ -515,6 +519,7 @@ private function load_dummy_response_data(): array { 'pricing' => $pricing, 'currencies' => $currencies, 'product' => $product, + 'coupons' => $coupons, ); return $data; diff --git a/includes/class-freemius-coupon.php b/includes/class-freemius-coupon.php new file mode 100644 index 0000000..ea2a4a0 --- /dev/null +++ b/includes/class-freemius-coupon.php @@ -0,0 +1,113 @@ + + * @license MIT + * @link https://freemius.com/ + */ + +namespace Freemius; + +/** + * Class Coupon + * + * @package Freemius + * @since 0.5.0 + */ +class Coupon { + /** + * Fetch coupon metadata by code. + * + * @since 0.5.0 + * + * @param int $product_id Product ID. + * @param string $code Coupon code. + * @return array|null Coupon data or null when not found. + */ + public static function get_by_code( int $product_id, string $code ): ?array { + if ( '' === $code ) { + return null; + } + + $api = Api::get_instance(); + $result = $api->get_request( + 'products/' . $product_id . '/coupons.json', + array( 'code' => $code ) + ); + + if ( \is_wp_error( $result ) ) { + return null; + } + + $data = $result->get_data(); + + if ( empty( $data['coupons'][0] ) || ! \is_array( $data['coupons'][0] ) ) { + return null; + } + + return $data['coupons'][0]; + } + + /** + * Whether a coupon applies to the given plan. + * + * @since 0.5.0 + * + * @param array $coupon Coupon metadata. + * @param int|string|null $plan_id Plan ID. + * @return bool + */ + public static function applies_to_plan( array $coupon, $plan_id ): bool { + if ( empty( $coupon['plans'] ) ) { + return true; + } + + $plan_ids = \array_map( 'trim', \explode( ',', (string) $coupon['plans'] ) ); + + return \in_array( (string) $plan_id, $plan_ids, true ); + } + + /** + * Apply coupon discount to a base price. + * + * @since 0.5.0 + * + * @param float $price List price. + * @param array $coupon Coupon metadata. + * @param string $currency Currency code. + * @return float Discounted price, floored at zero. + */ + public static function apply_discount( float $price, array $coupon, string $currency ): float { + $discounted = $price; + + if ( isset( $coupon['discount_type'] ) && 'percentage' === $coupon['discount_type'] ) { + $discounted = $price * ( 1 - ( (float) $coupon['discount'] / 100 ) ); + } else { + $currency_key = strtolower( $currency ); + $amount = $coupon['discounts'][ $currency_key ] ?? (float) $coupon['discount']; + $discounted = $price - (float) $amount; + } + + return max( 0.0, $discounted ); + } + + /** + * Coupon fields to embed on the frontend. + * + * @since 0.5.0 + * + * @param array $coupon Full coupon payload. + * @return array + */ + public static function to_embed_data( array $coupon ): array { + return array( + 'discount' => $coupon['discount'] ?? 0, + 'discount_type' => $coupon['discount_type'] ?? 'percentage', + 'plans' => $coupon['plans'] ?? null, + 'discounts' => $coupon['discounts'] ?? array(), + ); + } +} diff --git a/includes/class-freemius-scope.php b/includes/class-freemius-scope.php index 3f46cfd..bbf83f0 100644 --- a/includes/class-freemius-scope.php +++ b/includes/class-freemius-scope.php @@ -29,10 +29,19 @@ class Scope { /** * Whether the matrix has been added to the content * - * @var boolean + * @var array */ private $matrix_added = array(); + /** + * Coupon scripts already added to the page (product_id:coupon_code). + * + * @since 0.5.0 + * + * @var array + */ + private $coupon_added = array(); + /** * Constructor @@ -145,6 +154,26 @@ public function render_scope( $block_content, $block, $instance ) { // phpcs:ign $this->matrix_added[] = $product_id; } + $coupon_code = $args['coupon'] ?? ''; + if ( $product_id && ! empty( $coupon_code ) ) { + $coupon_key = $product_id . ':' . $coupon_code; + + if ( ! in_array( $coupon_key, $this->coupon_added, true ) ) { + $coupon = Coupon::get_by_code( (int) $product_id, (string) $coupon_code ); + + if ( null !== $coupon ) { + $extra .= sprintf( + '', + esc_attr( (string) $product_id ), + esc_attr( (string) $coupon_code ), + \wp_json_encode( Coupon::to_embed_data( $coupon ) ) + ); + + $this->coupon_added[] = $coupon_key; + } + } + } + $block_content = $extra . $block_content; return $block_content; diff --git a/includes/dummy-response.php b/includes/dummy-response.php index e6f35ff..020085a 100644 --- a/includes/dummy-response.php +++ b/includes/dummy-response.php @@ -18,3 +18,5 @@ $currencies = '{"currencies":["usd","gbp","eur"]}'; $product = '{"parent_plugin_id":null,"developer_id":"15334","store_id":"9196","install_id":"18226462","slug":"test-plugin","title":"Test Plugin","environment":0,"icon":"https:\/\/s3-us-west-2.amazonaws.com\/freemius\/plugins\/19794\/icons\/54c7fc13fab5c4b5acd1eebbc3aacce6.jpg","default_plan_id":"32841","plans":"32841,32842,32843","features":null,"money_back_period":null,"refund_policy":null,"annual_renewals_discount":null,"renewals_discount_type":"percentage","is_released":true,"is_sdk_required":true,"is_pricing_visible":true,"is_wp_org_compliant":true,"is_off":false,"is_only_for_new_installs":false,"installs_limit":null,"installs_count":0,"active_installs_count":0,"free_releases_count":0,"premium_releases_count":0,"total_purchases":0,"total_subscriptions":0,"total_renewals":0,"earnings":0,"commission":"","accepted_payments":0,"plan_id":"0","type":"plugin","is_static":false,"secret_key":"sk__","public_key":"pk_07813324de199d9ccfe7a4ff4f013","id":"19794","created":"2025-07-11 11:33:09","updated":"2025-08-25 10:54:52","helpscout_secret_key":"xxx"}'; + +$coupons = '{"coupons":[{"code":"SAVE20","discount":10,"discount_type":"percentage","plans":null,"discounts":{}}]}'; diff --git a/src/blocks/modifier/view.js b/src/blocks/modifier/view.js index 40219d9..c2445a2 100644 --- a/src/blocks/modifier/view.js +++ b/src/blocks/modifier/view.js @@ -10,6 +10,12 @@ import { store, getContext, getElement } from '@wordpress/interactivity'; /** * Internal dependencies */ +import { + applyCouponDiscount, + couponAppliesToPlan, + formatMappingPrice, + getCouponData, +} from '../../util/discountedPrice'; /** * Get the mapping content based on the mapping type and data @@ -52,6 +58,13 @@ function getMappingContent( scopeData, mappingData ) { let content = ''; + const licensesKey = sd.licenses || 'unlimited'; + + const getBasePrice = () => + matrix[ sd.plan_id ]?.pricing?.[ sd.currency ]?.[ sd.billing_cycle ]?.[ + licensesKey + ] ?? 0; + switch ( md.field ) { case 'billing_cycle': content = md.labels?.[ sd.billing_cycle ] ?? sd.billing_cycle; @@ -60,30 +73,42 @@ function getMappingContent( scopeData, mappingData ) { content = md.labels?.[ sd.licenses ] ?? sd.licenses; break; case 'price': - content = - matrix[ sd.plan_id ]?.pricing?.[ sd.currency ]?.[ - sd.billing_cycle - ]?.[ sd.licenses || 'unlimited' ] ?? 0; + case 'discounted_price': { + content = getBasePrice(); + + if ( md.field === 'discounted_price' ) { + if ( ! sd.coupon ) { + content = ''; + break; + } + + const coupon = getCouponData( sd.product_id, sd.coupon ); + + if ( ! coupon || ! couponAppliesToPlan( coupon, sd.plan_id ) ) { + content = ''; + break; + } + + content = applyCouponDiscount( content, coupon, { + currency: sd.currency, + } ); + } // it's an invalid plan if ( ! content && matrix[ sd.plan_id ]?.pricing !== null ) { - content = '-'; + content = md.field === 'discounted_price' ? '' : '-'; break; } - const symbol = md.currency_symbol; - - content = new Intl.NumberFormat( 'en-US', { - style: symbol !== 'hide' ? 'currency' : 'decimal', - currency: symbol !== 'hide' ? sd.currency : undefined, - minimumFractionDigits: 0, - } ).format( content ); + if ( content === '' ) break; - // extract the currency symbol - if ( symbol === 'symbol' ) - content = content.replace( /[\d\s.,]/g, '' ).trim(); + content = formatMappingPrice( content, { + currency: sd.currency, + currency_symbol: md.currency_symbol, + } ); break; + } default: content = matrix[ sd.plan_id ]?.[ md.field ] ?? ''; break; diff --git a/src/hooks/index.js b/src/hooks/index.js index 5473951..7ed919b 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -15,6 +15,7 @@ import useModifiers from './useModifiers'; import useLicenses from './useLicenses'; import usePlans from './usePlans'; import useProducts from './useProducts'; +import useCoupon from './useCoupon'; export { useSettings, @@ -26,6 +27,7 @@ export { useLicenses, usePlans, useProducts, + useCoupon, }; export { diff --git a/src/hooks/useCoupon.js b/src/hooks/useCoupon.js new file mode 100644 index 0000000..4b826ef --- /dev/null +++ b/src/hooks/useCoupon.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useApiGet } from './useApi'; + +/** + * Fetch coupon metadata for a product by code. + * + * @param {number|string|null} productId Freemius product ID. + * @param {string|null} couponCode Coupon code from scope settings. + * @return {Object} Coupon data and request state. + */ +const useCoupon = ( productId, couponCode ) => { + const enabled = Boolean( productId && couponCode ); + + const { data, isLoading, error, isApiAvailable } = useApiGet( + enabled ? `products/${ productId }/coupons.json` : null, + enabled ? { code: couponCode } : {}, + { enabled } + ); + + const coupon = useMemo( () => { + if ( ! enabled || ! data?.coupons?.length ) return null; + + return data.coupons[ 0 ]; + }, [ data, enabled ] ); + + const notFound = enabled && ! isLoading && ! error && ! coupon; + + return { + coupon, + isLoading: enabled && isApiAvailable && isLoading, + isError: Boolean( error ), + notFound, + }; +}; + +export default useCoupon; diff --git a/src/hooks/useMapping.js b/src/hooks/useMapping.js index 525c536..192aaca 100644 --- a/src/hooks/useMapping.js +++ b/src/hooks/useMapping.js @@ -11,7 +11,12 @@ import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ -import { useData, usePlans, useLicenses } from './'; +import { useData, usePlans, useLicenses, useCoupon } from './'; +import { + applyCouponDiscount, + couponAppliesToPlan, + formatMappingPrice, +} from '../util/discountedPrice'; const useMapping = ( props ) => { const { attributes, setAttributes } = props; @@ -24,6 +29,18 @@ const useMapping = ( props ) => { data?.product_id ); + const needsCoupon = freemius_mapping?.field === 'discounted_price'; + + const { + coupon, + isLoading: isCouponLoading, + isError: isCouponError, + notFound: isCouponNotFound, + } = useCoupon( + needsCoupon ? data?.product_id : null, + needsCoupon ? data?.coupon : null + ); + const defaultLabels = useMemo( () => { return { licenses: licenses.reduce( ( acc, license ) => { @@ -68,11 +85,33 @@ const useMapping = ( props ) => { setAttributes( newMapping ); }; - const value = getMappingValue( options ); + const couponContext = useMemo( + () => ( { + coupon, + isCouponLoading, + isCouponError, + isCouponNotFound, + } ), + [ coupon, isCouponLoading, isCouponError, isCouponNotFound ] + ); + + const value = getMappingValue( options, couponContext ); const errorMessage = []; - if ( value === undefined ) + if ( options.field === 'discounted_price' ) + if ( ! data?.coupon ) + errorMessage.push( + __( 'Coupon is required in scope settings', 'freemius' ) + ); + else if ( isCouponError || isCouponNotFound ) + errorMessage.push( __( 'Coupon not found', 'freemius' ) ); + else if ( coupon && ! couponAppliesToPlan( coupon, data?.plan_id ) ) + errorMessage.push( + __( 'Coupon does not apply to this plan', 'freemius' ) + ); + + if ( value === undefined && errorMessage.length === 0 ) errorMessage.push( sprintf( __( 'No value found for field %s', 'freemius' ), @@ -80,35 +119,28 @@ const useMapping = ( props ) => { ) ); - // if (!data.public_key) { - // errorMessage.push(__('Public key is required', 'freemius')); - // } - - // if (!data.product_id) { - // errorMessage.push(__('Product ID is required', 'freemius')); - // } - - // if (!data.plan_id) { - // errorMessage.push(__('Plan ID is required', 'freemius')); - // } - const isError = - ! isLoading && ! isLicensesLoading && errorMessage.length > 0; + ! isLoading && + ! isLicensesLoading && + ! isCouponLoading && + errorMessage.length > 0; return { value, options, setMapping, defaultLabels, - isLoading: isLicensesLoading || isLoading, + isLoading: isLicensesLoading || isLoading || isCouponLoading, isError, errorMessage: errorMessage.join( ', ' ), }; }; -const getMappingValue = ( options ) => { +const getMappingValue = ( options, couponContext = {} ) => { const { data, isLoading: isDataLoading } = useData(); const { plans, isLoading: isPlansLoading } = usePlans( data?.product_id ); + const { coupon, isCouponLoading, isCouponError, isCouponNotFound } = + couponContext; const currentPlan = useMemo( () => { return plans?.find( ( plan ) => plan.id == data?.plan_id ); @@ -123,47 +155,73 @@ const getMappingValue = ( options ) => { } ); }, [ currentPlan, data ] ); + const currency = + data?.currency && data?.currency !== 'auto' ? data?.currency : 'usd'; + const mappingData = useMemo( () => { + const basePrice = + currentPricing?.[ data?.billing_cycle + '_price' ] || undefined; + + let discountedPrice; + + if ( + options.field === 'discounted_price' && + basePrice !== undefined && + coupon && + couponAppliesToPlan( coupon, data?.plan_id ) + ) + discountedPrice = applyCouponDiscount( basePrice, coupon, { + currency, + } ); + return { - price: - currentPricing?.[ data?.billing_cycle + '_price' ] || undefined, // Free plan has no pricing - currency: - data?.currency && data?.currency !== 'auto' - ? data?.currency - : 'usd', + price: basePrice, + discounted_price: discountedPrice, + currency, title: currentPlan?.title || null, licenses: currentPricing?.licenses === null ? 0 - : currentPricing?.licenses, // handle unlimited license + : currentPricing?.licenses, billing_cycle: data?.billing_cycle, description: currentPlan?.description || null, }; - }, [ currentPricing, currentPlan, data ] ); + }, [ currentPricing, currentPlan, data, options.field, coupon, currency ] ); const newContent = useMemo( () => { + if ( options.field === 'discounted_price' ) { + if ( ! data?.coupon ) return undefined; + + if ( + isCouponLoading || + isCouponError || + isCouponNotFound || + ( coupon && ! couponAppliesToPlan( coupon, data?.plan_id ) ) + ) + return undefined; + } + let content = mappingData[ options.field ]; if ( typeof content === 'undefined' ) { // plans are loaded, but no pricing found => free plan if ( isPlansLoading ) return undefined; + if ( options.field === 'discounted_price' ) return undefined; + content = '0'; } - if ( options.field === 'price' && ! isNaN( content ) ) { - const symbol = options.currency_symbol; - - content = new Intl.NumberFormat( 'en-US', { - style: symbol !== 'hide' ? 'currency' : 'decimal', - currency: symbol !== 'hide' ? mappingData.currency : undefined, - minimumFractionDigits: 0, - } ).format( content ); - - // extract the currency symbol - if ( symbol === 'symbol' ) - content = content.replace( /[\d\s.,]/g, '' ).trim(); - } else if ( options.field === 'billing_cycle' ) + if ( + ( options.field === 'price' || + options.field === 'discounted_price' ) && + ! isNaN( content ) + ) + content = formatMappingPrice( content, { + currency: mappingData.currency, + currency_symbol: options.currency_symbol, + } ); + else if ( options.field === 'billing_cycle' ) content = options.labels[ mappingData.billing_cycle ] ?? mappingData.billing_cycle; @@ -178,9 +236,18 @@ const getMappingValue = ( options ) => { content = options.prefix + content + options.suffix; return content; - }, [ mappingData, options, isPlansLoading ] ); - - if ( isPlansLoading || isDataLoading ) return undefined; + }, [ + mappingData, + options, + isPlansLoading, + data, + coupon, + isCouponLoading, + isCouponError, + isCouponNotFound, + ] ); + + if ( isPlansLoading || isDataLoading || isCouponLoading ) return undefined; return newContent; }; diff --git a/src/scope/MappingSettings.js b/src/scope/MappingSettings.js index 4290eff..66db351 100644 --- a/src/scope/MappingSettings.js +++ b/src/scope/MappingSettings.js @@ -104,6 +104,10 @@ const MappingSettings = ( props ) => { name: __( 'Price', 'freemius' ), id: 'price', }, + { + name: __( 'Discounted price', 'freemius' ), + id: 'discounted_price', + }, { name: __( 'Title', 'freemius' ), id: 'title', @@ -125,7 +129,8 @@ const MappingSettings = ( props ) => { { options.field && ( <> - { options.field === 'price' && ( + { ( options.field === 'price' || + options.field === 'discounted_price' ) && ( id.trim() ) + .filter( Boolean ); + + return planIds.includes( String( planId ) ); +} + +/** + * Apply coupon discount to a base price. + * + * @param {number} basePrice List price before discount. + * @param {Object} coupon Coupon metadata from the Freemius API. + * @param {Object} options Options. + * @param {string} options.currency Currency code for fixed discounts. + * @return {number} Discounted price, floored at zero. + */ +export function applyCouponDiscount( basePrice, coupon, { currency } ) { + const price = Number( basePrice ); + + if ( ! coupon || isNaN( price ) ) return price; + + let discounted = price; + + if ( coupon.discount_type === 'percentage' ) + discounted = price * ( 1 - Number( coupon.discount ) / 100 ); + else { + const currencyKey = currency?.toLowerCase(); + const amount = + coupon.discounts?.[ currencyKey ] ?? Number( coupon.discount ); + + discounted = price - amount; + } + + return Math.max( 0, discounted ); +} + +/** + * Format a numeric price for mapping output. + * + * @param {number} value Numeric price. + * @param {Object} options Formatting options. + * @param {string} options.currency Currency code. + * @param {string} options.currency_symbol show | hide | symbol. + * @return {string} Formatted price string. + */ +export function formatMappingPrice( value, { currency, currency_symbol } ) { + const symbol = currency_symbol ?? 'show'; + + let content = new Intl.NumberFormat( 'en-US', { + style: symbol !== 'hide' ? 'currency' : 'decimal', + currency: symbol !== 'hide' ? currency : undefined, + minimumFractionDigits: 0, + } ).format( value ); + + if ( symbol === 'symbol' ) + content = content.replace( /[\d\s.,]/g, '' ).trim(); + + return content; +} + +/** + * Read embedded coupon metadata from the page. + * + * @param {number|string} productId Product ID. + * @param {string} couponCode Coupon code from scope. + * @return {Object|null} Coupon metadata or null when not embedded. + */ +export function getCouponData( productId, couponCode ) { + const nodes = document.querySelectorAll( '.freemius-coupon-data' ); + + for ( const node of nodes ) + if ( + node.dataset.freemiusProductId == productId && + node.dataset.freemiusCouponCode === couponCode + ) + return JSON.parse( node.textContent ); + + return null; +} diff --git a/src/util/index.js b/src/util/index.js index 8d541a5..3bf6b3d 100644 --- a/src/util/index.js +++ b/src/util/index.js @@ -28,3 +28,10 @@ const Dump = ( { props, title = '', visible = true } ) => { }; export default Dump; + +export { + applyCouponDiscount, + couponAppliesToPlan, + formatMappingPrice, + getCouponData, +} from './discountedPrice'; diff --git a/tests/php/CouponTest.php b/tests/php/CouponTest.php new file mode 100644 index 0000000..1a39123 --- /dev/null +++ b/tests/php/CouponTest.php @@ -0,0 +1,110 @@ + null, + ); + + $this->assertTrue( Coupon::applies_to_plan( $coupon, 32842 ) ); + } + + public function test_applies_to_plan_when_plan_is_listed(): void { + $coupon = array( + 'plans' => '32841,32842', + ); + + $this->assertTrue( Coupon::applies_to_plan( $coupon, 32842 ) ); + $this->assertFalse( Coupon::applies_to_plan( $coupon, 32843 ) ); + } + + public function test_apply_discount_percentage(): void { + $coupon = array( + 'discount' => 10, + 'discount_type' => 'percentage', + ); + + $this->assertSame( 45.0, Coupon::apply_discount( 50.0, $coupon, 'usd' ) ); + } + + public function test_apply_discount_dollar(): void { + $coupon = array( + 'discount' => 5, + 'discount_type' => 'dollar', + ); + + $this->assertSame( 45.0, Coupon::apply_discount( 50.0, $coupon, 'usd' ) ); + } + + public function test_apply_discount_dollar_uses_currency_specific_amount(): void { + $coupon = array( + 'discount' => 5, + 'discount_type' => 'dollar', + 'discounts' => array( + 'eur' => 9, + ), + ); + + $this->assertSame( 41.0, Coupon::apply_discount( 50.0, $coupon, 'eur' ) ); + } + + public function test_apply_discount_floors_at_zero(): void { + $coupon = array( + 'discount' => 100, + 'discount_type' => 'dollar', + ); + + $this->assertSame( 0.0, Coupon::apply_discount( 50.0, $coupon, 'usd' ) ); + } + + public function test_to_embed_data_returns_frontend_fields_only(): void { + $embedded = Coupon::to_embed_data( + array( + 'code' => 'SAVE20', + 'discount' => 10, + 'discount_type' => 'percentage', + 'plans' => '32842', + 'discounts' => array( 'usd' => 10 ), + 'redemptions' => 5, + ) + ); + + $this->assertSame( + array( + 'discount' => 10, + 'discount_type' => 'percentage', + 'plans' => '32842', + 'discounts' => array( 'usd' => 10 ), + ), + $embedded + ); + } + + public function test_get_by_code_returns_null_for_empty_code(): void { + $this->assertNull( Coupon::get_by_code( 19794, '' ) ); + } + + public function test_get_by_code_returns_coupon_from_api(): void { + $this->options['freemius_settings'] = array( + 'token' => '1234567890', + ); + + $coupon = Coupon::get_by_code( 19794, 'SAVE20' ); + + $this->assertIsArray( $coupon ); + $this->assertSame( 'SAVE20', $coupon['code'] ); + $this->assertSame( 'percentage', $coupon['discount_type'] ); + } +} diff --git a/tests/php/Freemius_TestCase.php b/tests/php/Freemius_TestCase.php index 600eca4..bcb7135 100644 --- a/tests/php/Freemius_TestCase.php +++ b/tests/php/Freemius_TestCase.php @@ -165,6 +165,11 @@ protected function reset_singletons(): void { $matrix->setAccessible( true ); $matrix->setValue( $scope, array() ); } + if ( $ref->hasProperty( 'coupon_added' ) ) { + $coupon_added = $ref->getProperty( 'coupon_added' ); + $coupon_added->setAccessible( true ); + $coupon_added->setValue( $scope, array() ); + } $api = Api::get_instance(); $api_ref = new ReflectionClass( $api ); diff --git a/tests/php/ScopeTest.php b/tests/php/ScopeTest.php index 4829984..5824be0 100644 --- a/tests/php/ScopeTest.php +++ b/tests/php/ScopeTest.php @@ -109,4 +109,52 @@ public function test_render_scope_deduplicates_matrix_per_product(): void { $this->assertSame( 1, substr_count( $first, 'freemius-matrix-data' ) ); $this->assertSame( 0, substr_count( $second, 'freemius-matrix-data' ) ); } + + public function test_render_scope_injects_coupon_data_when_coupon_is_set(): void { + $this->options['freemius_settings'] = array( + 'token' => '1234567890', + ); + + $scope = Scope::get_instance(); + $content = '
Original
'; + $block = array( + 'attrs' => array( + 'freemius_enabled' => true, + 'freemius' => array( + 'product_id' => 19794, + 'coupon' => 'SAVE20', + ), + ), + ); + + $result = $scope->render_scope( $content, $block, array() ); + + $this->assertStringContainsString( 'freemius-coupon-data', $result ); + $this->assertStringContainsString( 'data-freemius-coupon-code="SAVE20"', $result ); + $this->assertStringContainsString( '"discount_type":"percentage"', $result ); + } + + public function test_render_scope_deduplicates_coupon_per_product_and_code(): void { + $this->options['freemius_settings'] = array( + 'token' => '1234567890', + ); + + $scope = Scope::get_instance(); + $content = '
Block
'; + $block = array( + 'attrs' => array( + 'freemius_enabled' => true, + 'freemius' => array( + 'product_id' => 19794, + 'coupon' => 'SAVE20', + ), + ), + ); + + $first = $scope->render_scope( $content, $block, array() ); + $second = $scope->render_scope( $content, $block, array() ); + + $this->assertSame( 1, substr_count( $first, 'freemius-coupon-data' ) ); + $this->assertSame( 0, substr_count( $second, 'freemius-coupon-data' ) ); + } } From 5d86c8ab5f7a83fe0f9e10455e6e11977fef93a8 Mon Sep 17 00:00:00 2001 From: Xaver Date: Sun, 28 Jun 2026 12:25:53 +0200 Subject: [PATCH 2/3] refactor: enhance coupon plan validation and normalization Updated the `applies_to_plan` method in the Coupon class to utilize a new `normalize_plan_ids` method for improved handling of plan restrictions. This change allows for better normalization of plan IDs from various input formats. Additionally, the JavaScript utility function `normalizePlanIds` was added to mirror this functionality on the client side. New tests were introduced to ensure correct behavior when plans are provided as an array. This refactor improves code maintainability and consistency across PHP and JavaScript implementations. --- includes/class-freemius-coupon.php | 37 +++++++++++++++++++++++++++--- src/util/discountedPrice.js | 30 ++++++++++++++++++++---- tests/php/CouponTest.php | 9 ++++++++ 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/includes/class-freemius-coupon.php b/includes/class-freemius-coupon.php index ea2a4a0..3abb742 100644 --- a/includes/class-freemius-coupon.php +++ b/includes/class-freemius-coupon.php @@ -61,15 +61,46 @@ public static function get_by_code( int $product_id, string $code ): ?array { * @return bool */ public static function applies_to_plan( array $coupon, $plan_id ): bool { - if ( empty( $coupon['plans'] ) ) { + $plan_ids = self::normalize_plan_ids( $coupon['plans'] ?? null ); + + if ( empty( $plan_ids ) ) { return true; } - $plan_ids = \array_map( 'trim', \explode( ',', (string) $coupon['plans'] ) ); - return \in_array( (string) $plan_id, $plan_ids, true ); } + /** + * Normalize coupon plan restrictions from API payloads. + * + * @since 0.5.0 + * + * @param mixed $plans Plan restriction from API. + * @return array + */ + private static function normalize_plan_ids( $plans ): array { + if ( null === $plans || '' === $plans ) { + return array(); + } + + if ( \is_array( $plans ) ) { + return \array_values( + \array_filter( + \array_map( + static function ( $plan_id ) { + return \trim( (string) $plan_id ); + }, + $plans + ) + ) + ); + } + + return \array_values( + \array_filter( \array_map( 'trim', \explode( ',', (string) $plans ) ) ) + ); + } + /** * Apply coupon discount to a base price. * diff --git a/src/util/discountedPrice.js b/src/util/discountedPrice.js index 9c8f391..fbe649a 100644 --- a/src/util/discountedPrice.js +++ b/src/util/discountedPrice.js @@ -1,3 +1,26 @@ +/** + * Normalize coupon plan restrictions from API payloads. + * + * @param {string|number|Array|null|undefined} plans Plan restriction from API. + * @return {string[]|null} Plan IDs, or null when the coupon applies to all plans. + */ +function normalizePlanIds( plans ) { + if ( plans == null || plans === '' ) return null; + + if ( Array.isArray( plans ) ) + return plans.map( ( id ) => String( id ).trim() ).filter( Boolean ); + + if ( typeof plans === 'number' ) return [ String( plans ) ]; + + if ( typeof plans === 'string' ) + return plans + .split( ',' ) + .map( ( id ) => id.trim() ) + .filter( Boolean ); + + return null; +} + /** * Whether a coupon applies to the given plan. * @@ -6,12 +29,9 @@ * @return {boolean} True when the coupon applies to the plan. */ export function couponAppliesToPlan( coupon, planId ) { - if ( ! coupon?.plans ) return true; + const planIds = normalizePlanIds( coupon?.plans ); - const planIds = coupon.plans - .split( ',' ) - .map( ( id ) => id.trim() ) - .filter( Boolean ); + if ( ! planIds?.length ) return true; return planIds.includes( String( planId ) ); } diff --git a/tests/php/CouponTest.php b/tests/php/CouponTest.php index 1a39123..7c7ef83 100644 --- a/tests/php/CouponTest.php +++ b/tests/php/CouponTest.php @@ -30,6 +30,15 @@ public function test_applies_to_plan_when_plan_is_listed(): void { $this->assertFalse( Coupon::applies_to_plan( $coupon, 32843 ) ); } + public function test_applies_to_plan_when_plans_is_array(): void { + $coupon = array( + 'plans' => array( 32841, 32842 ), + ); + + $this->assertTrue( Coupon::applies_to_plan( $coupon, 32842 ) ); + $this->assertFalse( Coupon::applies_to_plan( $coupon, 32843 ) ); + } + public function test_apply_discount_percentage(): void { $coupon = array( 'discount' => 10, From a4f91954918907f2c218cbfa08946bc9a45de4f2 Mon Sep 17 00:00:00 2001 From: Xaver Date: Mon, 29 Jun 2026 12:42:38 +0200 Subject: [PATCH 3/3] chore: stop tracking .cursor on origin Keep Cursor config in a local nested repo under .cursor/ and ignore it in the plugin repository so it is not pushed to origin. Co-authored-by: Cursor --- .cursor/commands/do-release.md | 266 ----------- .cursor/commands/feature.md | 13 - .cursor/commands/issue.md | 47 -- .cursor/mcp.json | 1 - .cursor/rules/git-branch-workflow.mdc | 157 ------ .cursor/rules/wordpress-plugin.mdc | 61 --- .cursor/settings.json | 7 - .cursor/skills/feature/SKILL.md | 134 ------ .cursor/skills/issue/SKILL.md | 450 ------------------ .../wp-plugin-security-auditor/SKILL.md | 237 --------- .gitignore | 3 + 11 files changed, 3 insertions(+), 1373 deletions(-) delete mode 100644 .cursor/commands/do-release.md delete mode 100644 .cursor/commands/feature.md delete mode 100644 .cursor/commands/issue.md delete mode 100644 .cursor/mcp.json delete mode 100644 .cursor/rules/git-branch-workflow.mdc delete mode 100644 .cursor/rules/wordpress-plugin.mdc delete mode 100644 .cursor/settings.json delete mode 100644 .cursor/skills/feature/SKILL.md delete mode 100644 .cursor/skills/issue/SKILL.md delete mode 100644 .cursor/skills/wp-plugin-security-auditor/SKILL.md diff --git a/.cursor/commands/do-release.md b/.cursor/commands/do-release.md deleted file mode 100644 index 2b78f3e..0000000 --- a/.cursor/commands/do-release.md +++ /dev/null @@ -1,266 +0,0 @@ ---- -description: Prepare and cut a plugin release — README sync, tests, release branch and tag, then post-release version bump on develop ---- - -# Prepare and cut a Freemius release - -Run a **three-phase** release workflow. **Stop on any failure** — do not commit, tag, or push through red tests or unresolved CHANGELOG/README drift without calling it out and getting explicit user direction. - -**Changelog-first:** Day-to-day work updates [`CHANGELOG.md`](CHANGELOG.md) under `## [Unreleased]`. This command renames that section to the release version, syncs the WordPress.org-style changelog in [`README.md`](README.md), and tags the version already on `develop`. **Phase 3** bumps `develop` to the next version for ongoing feature work (`@since` alignment). - -## Overview - -```text -develop (version = release, ## [Unreleased] has bullets) - → pre-flight: release = package.json version (no patch bump) - → release/ — CHANGELOG/README/dates/tests only (no version bump) - → checkpoint → commit + tag - → checkpoint → optional push - → merge release/ → develop (user confirms) - → Phase 3: bump to next version + fresh ## [Unreleased] on develop -``` - -Pushing the tag triggers [`.github/workflows/release.yml`](.github/workflows/release.yml) (build, zip, Freemius pending release, GitHub release). Tag format: **``** with **no** `v` prefix (matches existing tag `0.1.0`). - ---- - -## Phase 1 — Pre-flight on `develop` (hard stops) - -Run **before** creating the release branch. Do **not** auto-merge feature branches into `develop`. - -| Check | Action if fail | -| --- | --- | -| **Current branch** | Must be **`develop`**. If on `feature/*`, `release/*`, or anything else → **stop**. Instruct: merge/rebase into `develop` first; do not auto-merge. | -| **Sync `develop`** | `git fetch origin`. Compare `develop` to `origin/develop`; `git pull origin develop` if behind (warn if offline / fetch fails). | -| **Working tree** | Prefer a **clean** tree on `develop`. If uncommitted changes exist → **stop** and show `git status -sb`. Continue only if the user **explicitly** accepts releasing with those changes. | - -Then **resolve release version** (next section). After resolution: - -| Check | Action if fail | -| --- | --- | -| **Duplicate branch/tag** | After fetch: `release/` must not exist locally or on `origin`. `git tag -l ''` and `git tag -l 'v'` must be empty locally; check remote tags after fetch. → **stop** (do not delete or reset branches/tags without user instruction). | -| **Version file drift** | `package.json`, [`freemius.php`](freemius.php) `Version:`, and README `Stable tag:` must all equal ``. → **stop** and list mismatches. | -| **CHANGELOG ready** | `## [Unreleased]` must exist with **at least one** bullet (`- ` lines). Empty section → **stop** before branching. | - ---- - -## Resolve release version - -1. Read `release` from [`package.json`](package.json): `jq -r '.version'`. -2. Verify it matches [`freemius.php`](freemius.php) `* Version:` and [`README.md`](README.md) `Stable tag:` — **stop** on drift. -3. Echo: `Releasing (version already on develop).` -4. **Do not** patch-bump, minor-bump, or catch up from CHANGELOG headings. The version on `develop` **is** the release. - -Optional `patch` / `minor` / `major` from the user applies only in [Phase 3](#phase-3--post-release-bump-on-develop) when bumping to the **next** development version — not when resolving `release`. - ---- - -## Phase 2 — Create `release/` branch (first git step after pre-flight) - -Still on **`develop`**, after pre-flight and version resolution succeed: - -```bash -git checkout develop -git pull origin develop # if network OK; else warn and continue with local develop -git checkout -b release/ -``` - -- All release edits, tests, commits, and tags happen **on this branch**, not on `develop`. -- **Do not** bump version files on the release branch — they already match ``. - ---- - -## Verify versioned files (no bump) - -Confirm these already equal `` (no edits unless drift was missed in pre-flight): - -| File | Field | -| --- | --- | -| [`package.json`](package.json) | `"version"` | -| [`package-lock.json`](package-lock.json) | Root and `"packages"."".version"` | -| [`freemius.php`](freemius.php) | `* Version: x.y.z` in plugin header | -| [`README.md`](README.md) | `Stable tag: x.y.z` | - ---- - -## WordPress “Tested up to” - -1. Fetch latest **stable** WordPress: `https://api.wordpress.org/core/version-check/1.7/?version=0` → use `offers[0].version` (not RC). -2. Update [`README.md`](README.md) line `Tested up to: `. -3. In the checkpoint summary, if `Requires at least:` is still below tested-up-to, **mention** whether to raise the minimum — do **not** change `Requires at least` without user OK. - ---- - -## CHANGELOG (release branch) - -Use **today’s date** in local timezone (`YYYY-MM-DD`). - -1. Rename `## [Unreleased]` → `## [] - YYYY-MM-DD` (keep bullets). -2. **Do not** insert a fresh `## [Unreleased]` here — Phase 3 creates it on `develop` after merge. - ---- - -## README changelog sync (guard before copy) - -**Goal:** README release notes must not ship bullets that only exist under `### Unreleased` and are missing from CHANGELOG `## [Unreleased]`. - -### Step A — Detect drift (stop unless user resolves) - -1. If [`README.md`](README.md) has a `### Unreleased` section under `## Changelog`, collect every bullet line (`- ` …) until the next `###` or non-bullet block. -2. Collect bullets from `## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md) (until the next `##`). -3. For each README unreleased bullet, check it appears in the CHANGELOG unreleased set (compare normalized text: trim, collapse whitespace; exact match after normalization is enough). -4. If **any** README unreleased bullet is **not** in CHANGELOG `## [Unreleased]` → **stop** before replacing `### Unreleased`. Report: - - - List bullets **only in README** (missing from CHANGELOG). - - List bullets **only in CHANGELOG** if helpful for context. - - Explain: routine dev should add user-facing notes to CHANGELOG; README `### Unreleased` is optional staging and must be a **subset** of CHANGELOG for this release. - -5. **User must choose** before continuing (do not auto-merge silently): - - **Merge into CHANGELOG** — add missing bullets under `## [Unreleased]` in CHANGELOG, then re-run the guard; or - - **Drop from README** — remove orphan unreleased bullets if they should not ship; or - - **Explicit override** — user says to release anyway and accept README losing those lines (document in summary). - -### Step B — Copy after guard passes - -1. Run [CHANGELOG (release branch)](#changelog-release-branch) first so `## []` exists with bullets. -2. Extract bullets from `## []` in CHANGELOG. -3. Under `## Changelog` in README: - - Replace `### Unreleased` (and its bullets) with `### ` and the **CHANGELOG** bullets (WordPress.org-style `###`, not `## [x.y.z]`). - - If there was no `### Unreleased`, insert `### ` at the top of the changelog section with copied bullets. -4. Update footer: `Version **** — see CHANGELOG.md`. -5. Set README changelog date: `### — YYYY-MM-DD` under `## Changelog`. - ---- - -## Verification (stop on failure) - -Run from plugin root in order: - -| Step | Command | -| --- | --- | -| JS deps (if needed) | `npm ci` | -| Lint (recommended) | `npm run lint:js` and `npm run lint:css` | -| Production build | `npm run build` | - -Capture stdout/stderr. On failure → **stop** (release branch may have uncommitted edits; do not commit or tag). - -**Scope warning:** Before commit, run `git status`. If files outside README/CHANGELOG changed, **warn** and only stage intended release files unless the user says otherwise. - ---- - -## Checkpoint 1 — summary (required before commit/tag) - -Present a compact summary: - -- Branch: `release/` (already created) -- Release version (already on `develop` — no version file bumps on this branch) -- CHANGELOG: `## [Unreleased]` renamed to `## [] - YYYY-MM-DD` -- Release date in CHANGELOG and README (`YYYY-MM-DD`) -- CHANGELOG ↔ README sync status (including any unreleased mismatch resolved) -- New `Tested up to` value -- Test / lint / build results (pass/fail) -- Files to be committed (list paths) -- Planned tag: `` (no `v` prefix) -- Reminder: pushing the tag triggers release workflow (Freemius pending + GitHub release) - -**Ask explicitly:** “Proceed with commit and tag on `release/`?” - -If **no** → **stop**. Leave the release branch and local changes for the user to finish manually. - ---- - -## Commit and tag (only after “yes”) - -On **`release/`**, stage release-intended files: - -```bash -git add README.md CHANGELOG.md -git commit -m "release: " -git tag -``` - -- **Stop** if a commit hook fails; do not amend unless hook auto-fixed files and project amend rules allow. -- Do **not** push unless the user confirms in Checkpoint 2. - ---- - -## Checkpoint 2 — push (optional) - -Ask: “Push `release/` and tag `` to origin?” - -If **yes**: - -```bash -git push -u origin release/ -git push origin -``` - ---- - -## Phase 3 — post-release bump on `develop` - -Run only after: - -1. User confirmed push in Checkpoint 2 (or tag exists locally and user skipped push intentionally), **and** -2. `release/` is merged into `develop` (merge now if user confirms; otherwise **stop** until user confirms the PR is merged). - -```bash -git checkout develop -git pull origin develop -# merge release/ if not already merged (user confirms): -# git merge release/ -``` - -Determine **next** version bump type: - -- Default: **patch** (`npm version patch --no-git-tag-version`) -- User said `minor` or `major` in the original `/do-release` invocation → use that for Phase 3 only - -```bash -NEXT=$(npm version patch --no-git-tag-version | tr -d v) # or minor / major -``` - -| File | What to update | -| --- | --- | -| [`package.json`](package.json) + [`package-lock.json`](package-lock.json) | via `npm version` | -| [`freemius.php`](freemius.php) | `* Version: $NEXT` | -| [`README.md`](README.md) | `Stable tag: $NEXT` | -| [`CHANGELOG.md`](CHANGELOG.md) | Insert empty `## [Unreleased]` at the top | - -```bash -git add package.json package-lock.json freemius.php README.md CHANGELOG.md -git commit -m "chore: bump to $NEXT for next development cycle" -``` - -- **Do not** tag `$NEXT`. -- **Checkpoint 3:** summary of bumped files + ask before push to `origin develop`. - -If **yes**: - -```bash -git push origin develop -``` - ---- - -## Post-release checklist (human) - -Tell the user to verify after Phase 3: - -- [ ] Tag push triggered **Create Release** -- [ ] GitHub release + Freemius pending release completed -- [ ] `release/` merged into `develop` -- [ ] Phase 3 bump landed on `develop` (`$NEXT` + fresh `## [Unreleased]`) so the next feature branch sees the correct version for `@since` -- [ ] Optional local smoke test: `npm run plugin-zip` - -**Out of scope:** Freemius secrets, WP.org `readme.txt` generation (CI uses root `README.md` + md2wp). - ---- - -## Example invocation - -User: **`/do-release`** - -Agent: pre-flight on `develop` → `release = package.json` → `release/` → CHANGELOG/README/dates (no version bump) → verify → summary → user “proceed” → commit + tag → optional push → merge → Phase 3 bump to next patch on `develop`. - -User: **`/do-release minor`** — Phase 3 uses a **minor** bump for the next development version (not for resolving `release`). diff --git a/.cursor/commands/feature.md b/.cursor/commands/feature.md deleted file mode 100644 index bb57e49..0000000 --- a/.cursor/commands/feature.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -description: Start implementation on a feature branch — match README roadmap when relevant, then branch and plan ---- - -# Feature - -Start **new implementation work** on a topic branch. Match the user’s rough topic to the **Roadmap** section in [`README.md`](../../README.md) when possible, then create `feature/` and outline next steps. - -Follow every step in [`.cursor/skills/feature/SKILL.md`](../skills/feature/SKILL.md). - -**Not for** filing GitHub issues (`/issue`) or cutting releases (`/do-release`). - -User passes a rough name after the command (e.g. `/feature pricing tables` or `/feature settings page`). If the topic is missing, ask once for a short name, then continue. diff --git a/.cursor/commands/issue.md b/.cursor/commands/issue.md deleted file mode 100644 index 09ca867..0000000 --- a/.cursor/commands/issue.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -description: GitHub issues — create new ones or pick an open issue and plan implementation ---- - -# Issue - -Work with **GitHub issues** via **`gh`**. Two modes — the skill picks based on what the user passes and session context. - -Follow every step in [`.cursor/skills/issue/SKILL.md`](../skills/issue/SKILL.md). - -**Not for** changelog-only commits. After planning from an existing issue, continue with **`/feature `** to branch and implement. - -## Modes - -| Invocation | Mode | -| --- | --- | -| `/issue` — no args, thin session | **Work** — list open issues, user picks one, plan implementation | -| `/issue 123` or `/issue #123` | **Work** — fetch that issue directly, plan implementation | -| `/issue ` or rich session with something to file | **Create** — draft and `gh issue create` | - -## Rules (both modes) - -1. **Auth first** — run `gh auth status` before anything else. If not logged in or missing repo scope, stop immediately with a clear explanation (`gh auth login` / `gh auth refresh`). -2. **Use `gh` yourself** — fetch, list, view, and create via CLI. Never browser automation or prefilled/shareable “new issue” URLs. -3. **Fail clearly** — explain auth, permissions, network, or missing-issue errors. On create failure, give title/body as copy-paste text only (no shareable link). -4. **Clickable issue link** — always return the issue as markdown the user can open in the browser, e.g. `[#123 — Title](https://github.com/owner/repo/issues/123)`. -5. **Branch relevance** — on `develop`/`main`/`master`, branch is neutral. On a topic branch, check whether it relates to the issue (PR links, `fixes #n`, branch name, commits, session) before citing it in Context or planning; if unrelated, omit and propose branching from `develop`. - -## Create mode extras - -- **Screenshots** — when the session has images, upload with `gh image --repo <owner/repo> <paths>` (`gh extension install drogers0/gh-image`; needs browser GitHub session via `gh image check-token`) and embed the returned markdown in the issue body. **Never** use placeholder `user-attachments` URLs. If upload fails, omit screenshots and tell the user to paste manually. -- **Discover before you draft** — read related code even when the session feels clear; surface factors that could change the approach. -- **Ask probing questions** — don’t only fill gaps; challenge assumptions, call out trade-offs, and use AskQuestion when a decision would materially change the issue. -- **Record what you learned** — put non-obvious constraints, risks, and open decisions in the issue body so future implementers are aware. -- **Gather git context**, then session — include branch/PR in the issue only when related. -- **Labels** — when creating, add applicable repo labels (`bug`, `enhancement`, `documentation`, etc.); see skill for the full mapping. -- **Issue type** — always set one of `Bug`, `Feature`, or `Task` via `--type` when creating (see skill). -- On success, link to the **newly created** issue. - -## Work mode extras - -- **No context** → fetch open issues, present a selectable list (AskQuestion when available). -- **Issue ID given** → skip the list; load that issue. -- Pull **body + comments** via `gh` to understand intent and constraints. -- Read relevant repo code, then produce a **short implementation plan** (branch slug, touch areas, steps, open questions). -- If current branch is unrelated to the issue, plan a **new** branch from `develop` — don’t inherit another topic’s WIP. -- Offer **`/feature <slug>`** to start coding when the plan looks right. diff --git a/.cursor/mcp.json b/.cursor/mcp.json deleted file mode 100644 index 0967ef4..0000000 --- a/.cursor/mcp.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/.cursor/rules/git-branch-workflow.mdc b/.cursor/rules/git-branch-workflow.mdc deleted file mode 100644 index 99654fe..0000000 --- a/.cursor/rules/git-branch-workflow.mdc +++ /dev/null @@ -1,157 +0,0 @@ ---- -name: "Git branch workflow" -description: "Always use a feature branch unless already on develop or main; branch creation is the first plan todo. Every plan ends with a copyable PR summary block (updated as the plan changes). Before finishing, reconcile CHANGELOG.md ## [Unreleased] with all user-facing branch changes—never bump versions during feature work." -alwaysApply: true ---- - -# Git branch workflow - -## When to use a feature branch - -**Default:** Always create and switch to a **feature branch** (`feature/<short-slug>`) before any implementation work. - -**Stay on the current branch** only when you are **already** on `develop` or `main` (or `master` if that is the repo’s default). Do not create a nested topic branch from those bases unless the user asks for one. - -**Exceptions** (follow the user’s instruction; do not insist on branching): - -- The user is explicit, for example: **"no branch"**, **"skip branch"**, **"commit on this branch"**, **"stay on develop"**, or similar. -- Checkout would fail due to uncommitted conflicts—alert the user instead of forcing branch creation. - -If the current branch is anything else (`feature/*`, `fix/*`, `release/*`, etc.), create a **new** feature branch from it only when starting **new** unrelated work; otherwise continue on the existing topic branch. - -## Plans and todos - -Whenever you **create a plan** (Plan mode, accepted plan, or a written implementation plan with todos): - -1. **First todo item (always):** Create or confirm the feature branch — e.g. `Create feature branch feature/<slug>` — unless already on `develop` or `main`, or the user said to skip branching. -2. Mark that todo **in progress** before any code or doc edits; mark it **completed** only after `git checkout -b feature/<slug>` (or confirming the correct branch is checked out). -3. Remaining plan steps follow after the branch step. -4. **Last todo item (always):** Reconcile [`CHANGELOG.md`](CHANGELOG.md) with the branch (see **Changelog (branch final step)** below). -5. **End every plan** with a **PR summary** block (see **PR summary (in plan)** below). Do **not** add a separate todo for it — keep the block at the bottom of the plan and update it whenever scope or approach changes. - -## Feature branch workflow - -**Cursor:** **`/feature <topic>`** ([`.cursor/commands/feature.md`](../commands/feature.md)) applies this section — propose `feature/<slug>`, create the branch, then outline a short plan. - -When starting work (including from a plan): - -1. **Suggest** a branch name like `feature/<short-slug>` (kebab-case from the plan’s main topic, e.g. `feature/checkout-button-settings`). -2. **Confirm** with the user unless they already gave a branch name or said to use no branch. -3. **Create** the branch only after the name is agreed: - - ```bash - git checkout -b feature/<chosen-slug> - ``` - -4. If there are working tree changes or possible conflicts, alert the user—never force branch creation. - -## Changelog (branch final step) - -Before finalizing the plan’s PR summary, ensure **`## [Unreleased]`** in [`CHANGELOG.md`](CHANGELOG.md) reflects **all user-facing changes on the current branch**, not only the latest commit or the work done in the current session. - -1. **Scope the branch** — Compare against `develop` (or `main` if that is the merge base): `git log develop..HEAD` and review the full diff (`git diff develop..HEAD`). -2. **Write for site owners and editors** — One bullet per user-visible outcome; skip internal-only work (tests, CI, developer docs, refactors with no user impact). Follow **Changelog (user-facing changes)** below for wording, grouping, and order. -3. **Cover the whole branch** — Replace or rephrase existing `[Unreleased]` bullets that belong to this branch so they summarize the feature/fix end-to-end. Do not leave bullets that describe only part of the branch or unrelated work. -4. **Incremental updates during work** — Add `[Unreleased]` bullets only when site owners or editors would care: **fixes**, **breaking changes**, **behavior changes**, or **improvements** they can notice. If a commit is internal, incremental, or not meaningfully user-facing on its own, **do not** add a line — fold it into the end-of-branch summary instead. Still run this reconciliation step before finalizing the PR summary. - -Do **not** create version headings or bump [`freemius.php`](../../freemius.php) during feature work (see **Version bumps**). - -## PR summary (in plan) - -Every plan ends with a **copyable PR summary** — a fenced markdown block the user can paste into GitHub when opening the PR. **No separate todo** for this; it lives at the bottom of the plan from the start. - -### When to write and update - -- **On plan creation** — add the block with your best draft from the plan (scope, approach, expected outcome). -- **When the plan changes** — revise the block in the same edit (added/removed steps, different files, scope cuts, new constraints). -- **Before opening the PR** — finalize against the full branch diff and reconciled `[Unreleased]` bullets so the summary matches what actually shipped. - -### Format - -Use this structure exactly inside a single fenced `markdown` code block under a heading such as `## PR summary`. **No checkboxes** (no test-plan checklists). - -```markdown -<type>: <subject> - -<summary> - -<explanation> - -<why> -``` - -- **`<type>`** — conventional prefix, e.g. `feat`, `fix`, `refactor`, `docs`, `chore` (lowercase). -- **`<subject>`** — short imperative title (what the PR does). -- **`<summary>`** — one or two sentences: outcome for reviewers. -- **`<explanation>`** — comprehensive but concise: what changed, how it works, main files/areas, and any migration or follow-up notes reviewers need. -- **`<why>`** — motivation, user impact, or problem solved. - -The block must be **self-contained and paste-ready** — no references to “see above” or plan-only shorthand. - -## Version bumps - -**Never bump versions during feature work.** - -- Do **not** change the `Version:` header in [`freemius.php`](../../freemius.php). -- Do **not** add or rename `## [x.y.z]` sections in [`CHANGELOG.md`](CHANGELOG.md). -- Do **not** guess the next semantic version while a branch is in progress. - -On `develop`, version files already track the **in-progress release** (bumped in **Phase 3** of `/do-release` after the previous release merges). The release branch tags that version without bumping again. - -- **Release branch:** rename `## [Unreleased]` → `## [x.y.z]` with date; no version file bumps. -- **Phase 3 on `develop`:** bump to the next version and insert a fresh `## [Unreleased]` (see [`.cursor/commands/do-release.md`](../commands/do-release.md)). - -## Changelog (user-facing changes) - -After each **meaningful change that affects end users** and belongs in the release notes, you may update **[`CHANGELOG.md`](CHANGELOG.md) only** as you go — but **only when a new line is warranted** (see **When to add an entry**). Before finishing the branch, always run **Changelog (branch final step)** so `[Unreleased]` matches the full branch. Do **not** update [`README.md`](README.md) Changelog as part of routine edits; README can be refreshed at release time or when the user asks. - -Do **not** log internal-only work (refactors, tests, CI, dev tooling, docs for developers) unless it clearly changes what site owners or editors experience. Prefer **fewer, clearer bullets** over logging every commit. - -### When to add an entry - -Add a line only when users would notice or care — meaningful **fixes**, **breaking changes**, **behavior changes**, or **improvements**, for example: - -- Bug fixes that affected real behavior -- Breaking changes, requirement changes, or migration notes -- New or changed features, blocks, or admin UI -- Performance or reliability improvements users can perceive - -**Do not** add a line when the change is not meaningfully user-facing on its own: typo fixes in code comments, dependency bumps with no user impact, purely structural refactors, partial implementation steps, or small internal tweaks. Wait and describe the **finished outcome** in one bullet at branch wrap-up when appropriate. - -### How to write entries - -- One short sentence per change, written for **site owners and editors**, not developers. -- Start with a lowercase verb prefix, e.g. `fixed`, `improved`, `added`, `changed`, `removed`, followed by a ":". -- Be specific about *what* changed and *where* it matters (e.g. checkout button block, settings page). - -### Order within a version section - -Group bullets by type in this order (most important for readers first): - -1. **`fixed`** — bugs and regressions users hit -2. **`improved`** — UX, performance, reliability users notice -3. **`added`** — new capabilities -4. **`changed`** / **`removed`** — behavior or requirement shifts (after the three groups above, or at the end of the section if few) - -Within each group, put the **highest-impact** items first (what site owners and editors care about most), not chronological order. - -When you add a line to the existing **`## [Unreleased]`** section, insert it in the right group and position; re-sort the section if needed so the order stays correct. - -Example bullets (correct order): - -```markdown -- fixed: checkout button not opening when product ID was missing from block attributes -- fixed: Save Settings button stays disabled after editing fields until you save or reload -- improved: checkout modal loading so repeat clicks feel faster -- added: support for multiple Freemius products in block settings -``` - -### Where to put new lines (version heading) - -**Always use `## [Unreleased]`** at the top of [`CHANGELOG.md`](CHANGELOG.md) (below the title/intro if any, above all dated or numbered release sections). This is the merge target for in-flight work on feature branches. - -1. If **`## [Unreleased]`** exists, add bullets there only. -2. If it does not exist, create **`## [Unreleased]`** at the top before adding bullets. -3. Do **not** create `## [next-version]` or guess a version number during feature work. - -At **release time** (release branch), rename `## [Unreleased]` to `## [x.y.z]` with the chosen version and date. A fresh empty **`## [Unreleased]`** section is added on `develop` in **Phase 3** of `/do-release` after merge. diff --git a/.cursor/rules/wordpress-plugin.mdc b/.cursor/rules/wordpress-plugin.mdc deleted file mode 100644 index beb04f8..0000000 --- a/.cursor/rules/wordpress-plugin.mdc +++ /dev/null @@ -1,61 +0,0 @@ ---- -name: "WordPress Plugin Development" -description: "Best practices, coding standards, and architecture rules for building high-quality WordPress plugins." -tags: ["wordpress", "plugin", "php", "oophp", "best-practices"] -alwaysApply: true ---- - -## 💡 Core Principles - -- Provide precise, technical PHP and WordPress plugin examples. -- Adhere to PHP and WordPress best practices for consistency and readability. -- Emphasize object-oriented programming (OOP) for modularity and maintainability. -- Avoid code duplication by applying modular design patterns. -- Use descriptive and meaningful function, class, variable, and file names. -- Directory naming: lowercase with hyphens (e.g., `my-plugin/includes`). -- Use WordPress hooks (actions and filters) to extend core behavior. -- Add clear, descriptive comments to explain non-trivial logic. - -## 🧼 PHP/WordPress Coding Practices - -- Use features from PHP 7.4+ where compatible (e.g., typed properties, arrow functions). -- Follow the [WordPress PHP coding standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/php/). -- Add `declare(strict_types=1);` at the top of each PHP file when possible. -- Always use WordPress core functions and APIs when available. -- Implement robust error handling: - - Use `WP_DEBUG_LOG` for logging. - - Apply `try-catch` blocks for controlled error management. -- Validate and sanitize all input using core functions (e.g., `sanitize_text_field()`, `esc_html()`). -- Escape all output appropriately. -- Verify nonces in all form submissions for CSRF protection. - -## 💾 Database Access - -- Use the `$wpdb` abstraction layer for all direct database access. -- Always use `prepare()` for dynamic queries to prevent SQL injection. -- Use `dbDelta()` for schema changes on plugin activation. - -## 📦 Dependencies - -- Ensure compatibility with the latest stable version of WordPress. -- Always use NPM (`@wordpress/scripts`) for JavaScript/CSS builds. -- Optionally use Composer for dependency management and autoloading. - -## 🧩 WordPress Plugin Best Practices - -- Use the WordPress Plugin API (actions/filters) for all extensibility. -- Register custom post types or taxonomies only when required. -- Manage plugin options via the [Settings API](https://developer.wordpress.org/plugins/settings/settings-api/). -- Store configuration using the Options API (`get_option()`, `update_option()`). -- Use `wp_enqueue_script()` and `wp_enqueue_style()` for managing scripts and styles. -- Protect plugin files from direct access with: - - ```php - defined( 'ABSPATH' ) || exit; - ``` - -## PHPDoc - -- Every **class**, **method**, and **function** in PHPCS-scanned PHP must include `@since X.Y.Z`. -- **New symbols:** use the current `Version:` from [`freemius.php`](../../freemius.php) — never placeholders like `1.0.0`. On `develop`, that version is the in-progress release (see `/do-release` Phase 3 post-release bump). -- **Placement:** after the short description (and blank line), before `@param`, `@return`, or `@var`. diff --git a/.cursor/settings.json b/.cursor/settings.json deleted file mode 100644 index f60e449..0000000 --- a/.cursor/settings.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "plugins": { - "superpowers": { - "enabled": true - } - } -} diff --git a/.cursor/skills/feature/SKILL.md b/.cursor/skills/feature/SKILL.md deleted file mode 100644 index 3562c60..0000000 --- a/.cursor/skills/feature/SKILL.md +++ /dev/null @@ -1,134 +0,0 @@ ---- -name: feature -description: >- - Start implementation on a feature branch: match README roadmap items when - relevant, follow git-branch-workflow, create feature/<slug>, and produce a - short plan. Use when the user invokes /feature or starts new feature work. -disable-model-invocation: true ---- - -# Feature branch kickoff - -Start **implementation** on a new topic. The **Roadmap** section in [`README.md`](../../../README.md) supplies scope when a bullet matches; branching follows [`.cursor/rules/git-branch-workflow.mdc`](../../rules/git-branch-workflow.mdc). - -**Not for:** filing GitHub issues (`/issue`) or releases (`/do-release`). - -## When to run - -User invokes **`/feature`** with a rough topic (title, slug fragment, or keywords), e.g. `/feature pricing tables` or `/feature customer portal`. - -## Hard stops - -| Check | Action if fail | -| --- | --- | -| **Already shipped** | Behavior is live and only changelog/README matter → **stop**; suggest updating [`CHANGELOG.md`](../../../CHANGELOG.md) under `## [Unreleased]`. | -| **Issue-only request** | User wants to file or pick a GitHub issue, not branch → **stop**; use `/issue`. | -| **No topic** | Empty message after `/feature` → ask once for a **short feature name** (3–8 words). | -| **Checkout blocked** | Uncommitted conflicts prevent `git checkout -b` → **stop**; report status; do not force. | -| **Protected branch commit** | User asked to implement on `main`/`master` without override → prefer branching from `develop`; do not commit feature work on `main`/`master`. | - -## Phase 1 — Parse topic and match roadmap - -1. Extract the **topic hint** from the user message (text after `/feature`, or the whole message if they did not use the slash form). -2. Read the **Roadmap** bullets under [`README.md`](../../../README.md) (`### Roadmap`): - - Support for Pricing tables - - Better support to measure analytics - - Dedicated Settings page - - Testimonials (from the API) -3. **Match** (best first): - - Keyword overlap with a roadmap bullet (e.g. `pricing` → pricing tables) - - Intent overlap with README feature descriptions or open GitHub issues when relevant -4. **Outcomes:** - - **One clear match** → treat that roadmap bullet as scope context for the plan. Tell the user which item you matched. - - **Several plausible matches** → list up to 3 with one-line labels; ask **once** which to follow (or confirm “none — greenfield”). - - **No match** → proceed from the user hint only. - -5. Skim related code (`includes/`, `src/`, blocks) for existing work on the topic (e.g. partial settings UI, pricing table code). - -## Phase 2 — Propose branch name - -Derive **`feature/<short-slug>`** (kebab-case, ~2–5 words): - -- Prefer a slug from the matched roadmap topic (e.g. pricing tables → `feature/pricing-tables`). -- Otherwise slugify the user’s topic hint. - -If the user passed an explicit branch or slug (e.g. `feature/customer-portal`), use it. - -**Confirm once** only when the slug is ambiguous and the user did not name one. Otherwise state the proposed branch and continue. - -### Branch rules ([`git-branch-workflow.mdc`](../../rules/git-branch-workflow.mdc)) - -| Current branch | Action | -| --- | --- | -| `develop`, `main`, `master` | Create `feature/<slug>` from current HEAD (if on `main`/`master`, prefer checking out `develop` first when it exists and is the team default). | -| `feature/*`, `fix/*`, … **same topic** | **Stay** on the branch; do not nest another feature branch. | -| `feature/*`, … **new unrelated topic** | Create a **new** `feature/<slug>` from current HEAD (warn if dirty tree might mix topics). | -| User: **no branch**, **skip branch**, **stay on develop**, **commit on this branch** | Skip `git checkout -b`; note the exception in the summary. | - -Run before branching (plugin root): - -```bash -git status -sb -git branch --show-current -``` - -Then, unless skipped: - -```bash -git checkout -b feature/<slug> -``` - -Uncommitted changes carry onto the new branch — **do not stash** unless checkout fails. - -## Phase 3 — Plan (no implementation yet) - -Produce a **short numbered plan** (5–10 bullets max) unless the user asked to implement immediately in the same message: - -1. **First item (always):** Confirm feature branch `feature/<slug>` — mark done only after checkout succeeds or stay decision is recorded. -2. Scope bullets from the matched roadmap item or user hint (requirements, open questions, out-of-scope). -3. Likely touch areas in the repo (`includes/`, `src/`, blocks, REST, admin) — hypotheses only until code is read. -4. Verification step (lint, build, manual check in block editor) when obvious from the topic. - -**Do not** edit product code in this command’s first response unless the user explicitly said to start implementing in the same message. Reading files for matching and planning is allowed. - -## Phase 4 — Report - -Summarize for the user: - -- **Topic** and **branch** (created, stayed, or skipped per override) -- **Roadmap:** matched README bullet, or “no roadmap match” -- **Plan** (numbered) -- **Open questions** from your code skim or ambiguous scope - -Commit only when the user asks. - -## Examples - -**Matched roadmap** - -User: `/feature pricing tables` - -Agent matches README “Support for Pricing tables”, proposes `feature/pricing-tables`, runs `git checkout -b feature/pricing-tables`, outputs plan with branch step first. - -**Ambiguous** - -User: `/feature settings` - -Agent lists “Dedicated Settings page” vs existing settings code in `includes/class-freemius-settings.php`, asks once, then branches and plans from the chosen direction. - -**Greenfield** - -User: `/feature customer portal` - -No roadmap match; proposes `feature/customer-portal`, branches, plan from user hint only. - -**Continue existing branch** - -User: `/feature customer portal` while already on `feature/customer-portal` - -Agent reports staying on `feature/customer-portal`, refreshes plan, does not create a nested branch. - -## Related commands - -- **`/issue`** — file or pick GitHub issues, then plan implementation -- **`/do-release`** — versioned release when work is ready to ship diff --git a/.cursor/skills/issue/SKILL.md b/.cursor/skills/issue/SKILL.md deleted file mode 100644 index f0a27e5..0000000 --- a/.cursor/skills/issue/SKILL.md +++ /dev/null @@ -1,450 +0,0 @@ ---- -name: issue -description: >- - GitHub issues via gh CLI: verify auth first, then either create a new issue - (discover related code, ask probing questions, draft with - Considerations, set issue type Bug/Feature/Task and labels) or pick an open issue (/issue or /issue 123), read body and - comments, and plan implementation. Never use prefilled shareable new-issue - URLs. Use when the user invokes /issue. -disable-model-invocation: true ---- - -# GitHub issue (gh CLI) - -Default repo: **`Freemius/freemius-wp-plugin`**. - -**Not for:** changelog-only commits. - -Two modes — pick **one** after Phase 0: - -| Mode | When | -| --- | --- | -| **Work** | `/issue` with no args and thin session; or `/issue <number>` / `/issue #<number>` | -| **Create** | `/issue <title hint>` (non-numeric text); or session already has a clear bug/feature to **file** | - -When in doubt: numeric-only arg → **Work**; descriptive text after `/issue` → **Create**; bare `/issue` with no filing intent in the chat → **Work**. - -## Phase 0 — Auth (first, always) - -Run **before** mode selection, git context, listing, or questions: - -```bash -gh auth status -``` - -| Result | Action | -| --- | --- | -| **Not logged in** | Stop. Tell the user to run `gh auth login`, then retry `/issue`. | -| **Logged in but missing repo access** | Stop. Explain which account is active; suggest `gh auth refresh` or the correct org login. | -| **OK** | Continue. | - -Never fall back to prefilled or shareable “new issue” URLs. - -Resolve repo early (both modes): - -```bash -gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null -``` - -Default `Freemius/freemius-wp-plugin` when cwd is the plugin and remote matches. - -## Branch relevance (both modes) - -The **current branch may be unrelated** to the issue (leftover WIP, different topic). Do not blindly attach branch/PR to Context or plans. - -```bash -git branch --show-current -gh pr view --json number,url,title,body,closingIssuesReferences 2>/dev/null -git log -5 --oneline -``` - -| Current branch | Action | -| --- | --- | -| **`develop`**, **`main`**, **`master`** | Neutral base — omit branch from issue **Context** unless a **related** open PR exists. | -| **`feature/*`**, **`fix/*`**, other topic branch | **Check relevance** before citing branch or suggesting “stay on current branch”. | - -**Treat branch/PR as related** only when at least one signal matches: - -- Issue number in PR body/title, `closingIssuesReferences`, or `fixes #n` / `#n` links -- Issue number or clear topic keywords in **branch name** or recent **commit messages** -- Issue body/comments already reference this branch or PR -- **Session** explicitly ties the issue to current branch work - -**If not related:** do not put branch/PR in the issue **Context**; in Work mode, propose a **fresh** `feature/<slug>` or `fix/<slug>` from `develop` (per git-branch-workflow) instead of continuing on the unrelated branch. Optionally note in chat: “Current branch `feature/other` looks unrelated — plan assumes branching from `develop`.” - -**If unsure:** ask once whether current WIP should be linked or ignored. - ---- - -# Mode A — Work on an existing issue - -Pick up an open issue, understand it from body + comments, and **plan implementation**. Do not create a new issue. Do not edit product code in the first response unless the user asked to implement in the same message. - -## A1 — Resolve issue number - -**Quick path** — user passed a number (`/issue 123`, `/issue #123`): - -```bash -gh issue view 123 --repo <owner/repo> --json number,title,body,state,labels,url -``` - -If **404** or not found → stop with a clear message. If **closed** → show link, note state, ask once whether to plan from it anyway. - -**List path** — bare `/issue`, no numeric arg, thin session: - -```bash -gh issue list --repo <owner/repo> --state open --limit 50 \ - --json number,title,labels,updatedAt,url -``` - -Present a **selectable list** (use **AskQuestion** when available): - -- One option per issue: `#<n> — <title>` (add label names when helpful). -- Sort by **most recently updated** unless the user asked otherwise. -- If more than ~15 issues, show the 15 most recent and mention total open count. -- If **zero** open issues → stop; suggest `/issue <title hint>` to file one. - -After selection (or quick path), always link the issue: `[#n — Title](url)`. - -## A2 — Load discussion - -Fetch comments with `gh` (read scope only — no shareable links): - -```bash -gh api repos/<owner>/<repo>/issues/<n>/comments \ - --jq '.[] | {author: .user.login, created_at, body}' -``` - -Or: - -```bash -gh issue view <n> --repo <owner/repo> --comments -``` - -Summarize for yourself: - -- **Problem / goal** from title + body -- **Constraints or decisions** from comments (especially maintainer replies) -- **Acceptance hints** — explicit checklists, repro steps, linked PRs -- **Gaps** — anything still unclear (one focused question max) - -## A3 — Repo context - -From the plugin repo root: - -```bash -git branch --show-current -git status -sb -``` - -Apply **Branch relevance** (above). If the current topic branch is **not** related to this issue, do not fold its WIP into the plan — suggest a new branch from `develop`. - -Read files the issue points at or that likely own the behavior (`includes/`, `src/`, blocks, REST, admin). - -## A4 — Implementation plan - -Produce a **short numbered plan** (5–10 bullets): - -1. **Suggested branch** — `feature/<slug>` or `fix/<slug>` from issue title. If current branch is **related**, you may say “stay on `feature/…`”; if **unrelated** or on `develop`/`main`/`master`, propose a **new** branch (do not checkout unless user asked). -2. **Summary** — one paragraph restating what “done” means for this issue. -3. **Approach** — concrete steps mapped to repo areas. -4. **Touch areas** — likely files/modules (hypotheses until read). -5. **Verification** — tests or manual checks when obvious from the issue. -6. **Open questions** — from comments, issue body, or code skim. - -End with: clickable issue link + offer **`/feature <slug>`** to branch and start coding. - ---- - -# Mode B — Create a new issue - -File a **new** GitHub issue with **`gh issue create`**. - -## Hard stops (create only) - -| Check | Action | -| --- | --- | -| **Duplicate** | Open issue same intent → show clickable link; ask before creating another | - -## B1 — Context (branch first) - -```bash -git branch --show-current -git status -sb -git log -3 --oneline -gh pr view --json number,url,title,state 2>/dev/null -``` - -Collect: branch, open PR, working tree, session hint, relevant code paths. - -Apply **Branch relevance** — include branch and PR in the issue **Context** section **only when related** to what is being filed. On `develop`/`main`/`master` with no related PR, omit branch lines. - -**Dedupe:** - -```bash -gh issue list --repo <owner/repo> --search "in:title <keywords>" --state open --limit 10 -``` - -Also scan for **related open issues/PRs** beyond title dedupe — same subsystem, opposite intent, or a dependency: - -```bash -gh issue list --repo <owner/repo> --search "<subsystem keywords>" --state open --limit 5 -gh pr list --repo <owner/repo> --search "<subsystem keywords>" --state open --limit 5 -``` - -## B2 — Discover and clarify (think wider) - -**Do not rush to draft.** Even when goal and scope sound obvious from the chat, run a short **discovery pass** first, then ask **targeted questions** about anything that would change how the issue is written or implemented. - -### Discovery pass (before questions or draft) - -1. **Read** files the session points at — and **one level outward** (callers, hooks, stored options, REST routes, editor vs checkout vs admin). -2. **Note cross-cutting factors** that might affect the issue — record them even if you don’t ask about every one: - -| Area | Ask yourself | -| --- | --- | -| **Architecture** | Block editor, settings, REST/API, checkout modal — which layer owns this? | -| **Stored state** | Options API product settings — backward compatible? | -| **Checkout / API** | Freemius product/plan IDs, API token handling — breaking changes? | -| **Extensibility** | Filters and hooks — breaking change for third parties? | -| **Admin vs front** | Block editor, settings screen, checkout modal — where does behavior surface? | -| **Integrations** | Block themes, page caches — assumptions to state explicitly? | -| **Related work** | Open issues/PRs on the same subsystem — sequence, duplicate, or dependency? | -| **Release impact** | User-visible changelog? Screenshots? Support burden? | - -### When to ask questions - -Use **AskQuestion** when available. Prefer **one focused round** (2–4 questions max) that surfaces **decisions**, not trivia. - -**Always ask** when goal, type, scope, title, duplicate, or repo is unknown. - -**Also ask** when discovery reveals any of these — even if the user seemed clear: - -- **Multiple valid approaches** with different trade-offs (e.g. option vs meta, sync vs async, editor vs server render). -- **Scope creep risk** — fix touches block editor + settings + API; confirm what’s in v1. -- **Breaking or migration risk** — existing sites, stored product settings, API shape. -- **Product tension** — UX simplicity vs power-user knobs -- **Conflict** — open issue or in-flight PR says something different. -- **Missing “why now”** — bug without repro, feature without user story, refactor without pain. - -**Question style:** concrete and decision-forcing — “Should v1 support multiple products in the button block or settings only?” not “Any preferences?” - -If the user already answered in the session, **state your assumptions** in the draft instead of re-asking — but still call out discovered risks in **Considerations**. - -### If user declines to answer - -Proceed with best-effort draft; label unknowns under **Open questions** and **Considerations** so implementers see the gaps. - -## B3 — Draft - -**Title:** imperative, ~8–14 words, no trailing period. - -**Body** (omit empty sections): - -```markdown -## Summary - -<what and why> - -**Goal:** <outcome> - -## Problem - -<current behavior or gap> - -## Proposed approach - -1. ... -2. ... - -## Out of scope - -- ... - -## Acceptance criteria - -- [ ] ... -- [ ] ... - -## Considerations - -<non-obvious constraints, risks, migrations, cache/version bumps, extensibility, related issues/PRs — from discovery; omit if none> - -## Open questions - -- ... - -## Context - -- Branch: `<current-branch>` (only if related to this issue) -- PR: #<n> <url> (only if related) -- <session note> -``` - -When you asked clarifying questions, show **title + summary + type + labels + key considerations** before publishing; otherwise draft and publish, but still include **Considerations** when discovery found anything non-obvious. - -### Screenshots (create mode) - -When the session includes images (user paste, `<image_files>`, or paths under `~/.cursor/projects/.../assets/image-*.png`), embed them in the issue **before** `gh issue create` — or `gh issue edit` immediately after if create already ran without them. - -**Never** invent `https://github.com/user-attachments/assets/...` URLs or placeholder paths. Broken image links are worse than no screenshots. - -#### Upload via `gh image` (preferred) - -GitHub’s REST API cannot attach binary files to issues. Use the **`gh-image`** extension, which replicates the browser upload flow and returns real `user-attachments` markdown: - -```bash -# One-time setup (per machine) -gh extension install drogers0/gh-image -gh image check-token # must succeed — needs github.com logged in via browser -``` - -Upload session images and capture markdown (replace alt text with descriptive labels): - -```bash -gh image --repo <owner/repo> /path/to/editor.png /path/to/output.png -``` - -Example output (rewrite alts before embedding): - -```markdown -![Block editor — button settings](https://github.com/user-attachments/assets/<uuid>) -![Checkout modal — open state](https://github.com/user-attachments/assets/<uuid>) -``` - -Add a `### Screenshots` subsection under **Problem** when visuals help repro. - -| Result | Action | -| --- | --- | -| **`gh image check-token` fails** | Run `gh image extract-token` or log into github.com in the default browser, then retry. | -| **Upload fails** | Omit `### Screenshots` from the body. Note in the create report that images could not be uploaded and the user should drag-drop them into the issue in the browser. **Do not** use fake URLs. | -| **No session images** | Skip the section unless the user explicitly asks for a placeholder note. | - -#### Fallback (no browser session) - -Only when `gh image` is unavailable and commits are acceptable: push PNGs via the Contents API and reference `https://github.com/{owner}/{repo}/raw/{branch}/{path}` (works for private repos when the viewer is authenticated; does not render in email). See [GitHub awesome-copilot images reference](https://github.com/github/awesome-copilot/blob/main/skills/github-issues/references/images.md). - -### Issue type (create mode) - -Always set **exactly one** GitHub issue type when creating — use `--type` with the **exact** name: - -| Type | When to apply | -| --- | --- | -| **Bug** | Unexpected problem or broken behavior | -| **Feature** | Request, idea, or new functionality | -| **Task** | Specific piece of work — refactor, tech debt, migration, docs, cleanup, chore | - -Pick from discovery and clarifying questions. If both **Feature** and **Task** fit, prefer **Feature** when user-facing capability changes; **Task** when internal/maintenance work only. - -**Typical pairings with labels** (type + label are independent — set both when applicable): - -| Type | Common labels | -| --- | --- | -| **Bug** | `bug`; add `help wanted` when repro or scope is thin | -| **Feature** | `enhancement`; add `help wanted` when relevant | -| **Task** | `documentation` for docs-only; `good first issue` for small scoped work; often no primary label | - -Omit `--type` only when classification is genuinely unknown after discovery — prefer asking once over guessing wrong. - -### Labels (create mode) - -When applicable, add **one primary type label** and optional **secondary** labels via `--label`. Confirm names exist: - -```bash -gh label list --repo <owner/repo> --limit 20 -``` - -**`Freemius/freemius-wp-plugin` labels** (use exact names): - -| Label | When to apply | -| --- | --- | -| **bug** | Broken behavior, regression, incorrect output | -| **enhancement** | New feature or improvement to existing behavior | -| **documentation** | Docs-only change (user guide, dev docs, README) | -| **question** | Filing to track an open question — scope still unclear after discovery | -| **help wanted** | Needs extra input (design, product call, external blocker) | -| **good first issue** | Small, well-scoped, low-risk; clear steps for a newcomer | - -**Do not apply** on create unless the user explicitly asks: `duplicate`, `invalid`, `wontfix`. - -Pick from discovery and clarifying questions — e.g. bug + `help wanted` when repro is thin. Omit labels when none fit confidently (type unknown). - -### Assignee (create mode) - -Omit `--assignee` unless the user asks for a specific person. - -## B4 — Publish - -**Screenshots first** (when the session has images): run `gh image --repo <owner/repo> …`, rewrite alts, embed the returned markdown in the draft body, then create or edit the issue. Never publish placeholder `user-attachments` URLs. - -```bash -gh issue create --repo <owner/repo> \ - --title "<title>" \ - --type "<Bug|Feature|Task>" \ - --label "<primary>" \ - --label "<optional-secondary>" \ - --body "$(cat <<'EOF' -<body> -EOF -)" -``` - -Omit `--type` only when classification is unknown. Omit `--label` flags when no label applies. Add `--assignee` only when the user requests it. - -Use **`required_permissions: ["all"]`** for `gh` commands. - -### If `gh issue create` fails - -Stop and explain (403 → Issues read/write on PAT + `gh auth refresh`; 404 → repo; network → retry later). Provide drafted title/body as copy-paste text only. **Do not** generate a shareable “new issue” URL. - -## B5 — Report (create) - -- **Clickable link:** `[#n — Title](url)` for the new issue -- Title + one-line summary -- **Type** (`Bug` / `Feature` / `Task`) and **labels** applied (or note what was omitted) -- **Assignee:** only when user requested one -- **Screenshots:** confirm embedded via `gh image`, or note if upload failed and manual paste is needed -- Branch / PR in Context **only if related** (say so in chat if omitted) -- Duplicate noted if skipped - -Do not commit unless the user asks. - ---- - -## Examples - -**Work — pick from backlog** - -`/issue` with no session → auth → list open issues → user picks #87 → read body + comments → plan → link + suggest `/feature`. - -**Work — quick ID** - -`/issue 42` → auth → `gh issue view 42` + comments → plan → `[#42 — …](url)`. - -**Work — unrelated branch** - -`/issue 42` while on `feature/customer-portal` (issue is about checkout button) → relevance check fails → plan proposes `fix/checkout-button-…` from `develop`, does not assume customer-portal WIP applies. - -**Create — clear session** - -On **related** `feature/pricing-tables` with open PR after design thread → `/issue` with filing intent → branch + PR in Context → `gh issue create`. - -**Create — title hint** - -`/issue add pricing table block` → read blocks + API code → ask about editor-only vs settings persistence if unclear → **Considerations** notes block API → `gh issue create`. - -**Create — discovery surfaces migration** - -Session proposes new option shape → discovery finds existing sites store product IDs in options → ask scope + record **Considerations** / migration note → type **Feature**, label `enhancement` → publish. - -**Create — small docs fix** - -`/issue update README installation steps` → type **Task**, label `documentation` → `gh issue create`. - -**Create — with session screenshots** - -Session includes editor vs checkout PNGs → `gh image --repo Freemius/freemius-wp-plugin editor.png checkout.png` → embed returned markdown in `### Screenshots` → `gh issue create`. If create already ran with broken placeholders, `gh issue edit <n> --body` with real URLs. - -## Related commands - -- **`/feature`** — branch + implement (natural follow-up after Work mode plan) diff --git a/.cursor/skills/wp-plugin-security-auditor/SKILL.md b/.cursor/skills/wp-plugin-security-auditor/SKILL.md deleted file mode 100644 index cb6bc2c..0000000 --- a/.cursor/skills/wp-plugin-security-auditor/SKILL.md +++ /dev/null @@ -1,237 +0,0 @@ ---- -name: wp-plugin-security-auditor -description: >- - WordPress plugin security audit (SQLi, XSS, CSRF, auth, REST, dangerous - functions, dependencies). Produces AUDIT_REPORT.md and GHSA advisories. Use - when the user requests a security audit — not feature kickoff or - release workflows. -disable-model-invocation: true ---- - -# WP Plugin Security Auditor - -**Communication style:** Respond tersely. Drop articles (a/an/the), filler words (just/really/basically/actually/simply), pleasantries (sure/certainly/happy to/of course), and hedging. Fragments OK. Prefer short synonyms (fix not "implement a solution for", big not "extensive"). Technical terms stay exact. Code blocks unchanged. Pattern: `[thing] [action] [reason]. [next step].` - -Activate when user requests security audit of WP plugin. Produces GHSA findings + security report. - -## Mission - -Security audit WP plugin. Generate: - -1. **Security report** — AUDIT_REPORT.md with findings + scores -2. **Security advisories** — GHSA-format vuln files (when issues found) - -## Exclude Dirs - -NEVER analyze (all searches/greps): -`/vendor/` `/lib/vendor/` `/dist/` `/.git/` `.*` `/dev-workspace-cache/` `/dev-workspace/` `/node_modules/` `/tests/` - -## Audit Methodology - -### Phase 1: Vulnerability Search - -Grep tool, exclude vendor/lib/tests/dist/dev-workspace: - -**SQL Injection:** -- Pattern: `\$wpdb->get_results.*\$` or `\$wpdb->query.*\$` without `prepare()` -- Flag: direct string interpolation in SQL - -**XSS:** -- Pattern: `echo \$_(POST|GET|REQUEST)` without escaping -- Flag: unescaped output, missing `esc_html()`/`esc_attr()`/`esc_url()` -- Flag: React `dangerouslySetInnerHTML` + user input - -**CSRF:** -- Pattern: form/AJAX handlers without `wp_verify_nonce()` -- Flag: POST handlers missing nonce, admin forms without nonce fields - -**Auth & Authorization:** -- Pattern: missing `current_user_can()` before privileged ops -- Flag: weak API keys, insecure REST endpoints, missing capability checks, REST routes without `permission_callback` - -**Dangerous Functions:** -- Pattern: `eval\(|exec\(|system\(|shell_exec\(|passthru\(|base64_decode\(` -- Flag: `unserialize()` with user input, `create_function()` - -**File Ops:** -- Pattern: `move_uploaded_file()`, `file_put_contents()` -- Flag: missing validation, path traversal (`../`), no extension checks - -### Phase 2: WP-Specific Checks - -- Input sanitized: `sanitize_text_field`, `sanitize_email`, `absint`, etc. -- Output escaped: `esc_html`, `esc_attr`, `esc_url`, `wp_kses`, etc. -- `$wpdb->prepare()` on all dynamic SQL -- Nonces on all forms + AJAX -- `current_user_can()` before privileged ops -- REST API `permission_callback` implementations -- Hooks/filters for injection points -- `wp_remote_*` vs curl - -### Phase 3: Dependencies - -Check composer.json: -- Outdated pkgs (3+ years = HIGH RISK) -- Stripe (current: v13+), PayPal -- Unmaintained libs (no updates 2+ years) -- PHP min 7.4, WP version req - -Payment security (if applicable): -- Stripe: SDK version, API version, PCI patterns, webhook verification -- PayPal: IPN/webhook handling, payment verification -- API keys: wp_options or constants, NOT plaintext -- Card data: must NOT exist (PCI violation) - -## Security Scoring (0-5.0, one decimal) - -| Score | Grade | Criteria | -|-------|-------|----------| -| 4.5-5.0 | Excellent | No significant issues, follows best practices | -| 3.5-4.4 | Good | Minor issues, easily fixable | -| 2.5-3.4 | Fair | Some concerns, patchable (outdated deps, weak validation) | -| 1.5-2.4 | Poor | Serious issues (outdated payment SDKs, weak auth, no CSRF) | -| 0.0-1.4 | Critical | Active vulns (SQLi, auth bypass, XSS) | - -Grade each finding: CRITICAL / HIGH / MEDIUM / LOW with file:line refs. - -## Recommendation Logic - -- **HEALTHY:** Security ≥4.0 -- **NEEDS-WORK:** Security 2.5-3.9 -- **CRITICAL:** Security <2.5 - -## Output Format - -### Output 1: AUDIT_REPORT.md - -```markdown -# [PLUGIN_NAME] Security Audit - -## SPREADSHEET DATA - -**IMPORTANT**: TAB characters between columns, not spaces. - - ``` - Metric Score/Value Notes - Security Score [X.X] [Main findings, max 2 sentences] - Recommendation [HEALTHY/NEEDS-WORK/CRITICAL] [One-line rationale] - ``` - -## 1. Security Assessment - -### Score: X.X/5.0 - -🔴 **Critical Issues:** -- [Issue] (file:line) - -🟡 **Concerns:** -- [Issue] (file:line) - -🟢 **Strengths:** -- [Positive finding] - -## 2. Dependencies - -**Critical Issues:** [Outdated/risky pkgs] -**Immediate Updates:** [What needs updating] -**PHP Version:** [Current req] -**WordPress Version:** [Current req] - -## 3. Final Recommendation: [HEALTHY/NEEDS-WORK/CRITICAL] - -**Rationale:** [2-3 sentences based on decision rules] - -**Key Factors:** -- [Factor 1] -- [Factor 2] -- [Factor 3] -``` - -### Output 2: GHSA Advisory Files - -Per vuln found → separate `.md` in `/security-audit/`. - -**Naming:** `[plugin-name]-[###]-[SEVERITY]-[short-description].md` -- `myplugin-001-CRITICAL-sql-injection-custom-query.md` -- `myplugin-002-HIGH-xss-unescaped-output.md` -- `myplugin-003-MEDIUM-csrf-missing-nonce.md` - -Rules: sequential from 001, SEVERITY uppercase, description kebab-case, only create if vulns found. - -**GHSA format:** - -```markdown -## Security Advisory - -### Summary -[One-line description] - -### Severity -[Critical / High / Medium / Low] - -### CVSS Score -[CVSS 3.1 score, e.g., 8.8 (High)] - -### CWE -[CWE ID + name, e.g., CWE-89: SQL Injection] - -### Affected Versions -[Version range or "all versions"] - -### Vulnerability Details -**Type:** [type] -**Location:** [file:line] -**Attack Vector:** [Network/Local] -**User Interaction:** [Required/None] -**Privileges Required:** [None/Low/High] - -### Description -[Technical description: what code does, why vulnerable, what attacker achieves] - -### Proof of Concept -[Steps or payloads] - -### Vulnerable Code - ```php - [Snippet with file:line] - ``` - -### Remediation -[Fix with corrected code] - ```php - [Fixed snippet] - ``` - -### References -- [OWASP links] -- [WP security docs] -``` - -## Security Accuracy Checklist - -1. ✅ file:line refs accurate + verifiable -2. ✅ Vulns exploitable, not just theoretical -3. ✅ Code path reachable by users -4. ✅ WP core sanitization doesn't already block it -5. ✅ CVSS scores reflect actual impact -6. ✅ Recommendation follows decision rules - -**False positive check:** sanitized before vuln fn? capability check earlier in stack? nonce in parent fn? output escaping before XSS call? Trace data flow multi-file if needed. - -## Execution Workflow - -1. **Grep vulns:** SQLi, XSS, CSRF, dangerous fns, file op risks -2. **Verify WP practices:** sanitization, escaping, nonces, capability checks, REST callbacks -3. **Review deps:** composer.json — outdated pkgs, payment SDK versions, unmaintained libs -4. **Score:** Security 0-5.0 -5. **Apply recommendation logic** -6. **Generate:** AUDIT_REPORT.md + GHSA files per vuln - -## Interaction Protocol - -- Request files if needed to trace data flow -- Explain severity reasoning when unclear -- Highlight systemic patterns -- Actionable remediation, not just problem ID -- Uncertain about exploitability → report with caveats -- Complete full workflow before final report diff --git a/.gitignore b/.gitignore index 5d6d665..cd3bd95 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ vendor/ .env /vendor/ + +# Cursor IDE config (local nested repo; not shared on origin) +.cursor/