PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
better-payment / includes / Campaign / Templates / CategoryRegistry.php

CategoryRegistry.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.4.1, at includes/Campaign/Templates/CategoryRegistry.php

250 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\Campaign\Templates;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 /**
10 * The campaign category taxonomy — the single source of truth.
11 *
12 * These are the categories the template picker lists in its sidebar and the AI
13 * Smart Prompt Wizard offers under "What are you raising funds for?". Both used
14 * to hardcode their own list (`CATEGORIES` in TemplatesTab.js and `CAUSES` in
15 * PromptWizard.js), which is why they drifted: the picker showed five categories
16 * while the wizard offered a different ten, overlapping on only two.
17 *
18 * Each category declares:
19 * - `slug` The key a template's `tags` entry matches (see TemplateManager).
20 * - `label` Translated display text — shown in the picker sidebar and wizard.
21 * - `brief` Untranslated English used in the AI brief ("for a medical cause").
22 * Deliberately NOT `__()`: the brief is prompt text for an
23 * English-instructed model, so a translated label here would
24 * produce a mixed-language sentence.
25 * - `photo` Hero image, relative to `assets/img/campaign/templates/` — the
26 * same artwork the prebuilt templates in that category already use.
27 * - `keywords` Terms that identify this category in free text (see match_text).
28 *
29 * **Not memoized, unlike TemplateManager::get_all().** That one is memoized
30 * because it runs on every public campaign pageview and carries full layouts;
31 * this list is small and only read in wp-admin (the builder localization and AI
32 * generation). Skipping the memo means a filter registered late still applies,
33 * and tests need no static reset.
34 *
35 * @see \Better_Payment\Lite\Campaign\Templates\TemplateManager Templates tagged with these slugs.
36 * @see \Better_Payment\Lite\AI\Services\CampaignGenerator Uses `photo` for AI-generated campaigns.
37 */
38 class CategoryRegistry {
39
40 /**
41 * The built-in categories, in the order the picker sidebar lists them.
42 *
43 * @return array<string, array> Keyed by slug.
44 */
45 private static function defaults(): array {
46 return [
47 'charity' => [
48 'slug' => 'charity',
49 'label' => __( 'Charity', 'better-payment' ),
50 'brief' => 'charity',
51 'photo' => 'charity/Refugee-Relief-Fund.webp',
52 'keywords' => [
53 'charity', 'charitable', 'nonprofit', 'non-profit', 'ngo',
54 'humanitarian', 'refugee', 'homeless', 'homelessness',
55 'poverty', 'food bank', 'orphan', 'orphanage',
56 'animal', 'animals', 'animal rescue', 'shelter', 'rescue',
57 ],
58 ],
59 'club-organizations' => [
60 'slug' => 'club-organizations',
61 'label' => __( 'Club / Organizations', 'better-payment' ),
62 'brief' => 'club or organization',
63 'photo' => 'club-organizations/Elite-Golf-Club-Membership.webp',
64 'keywords' => [
65 'club', 'membership', 'organization', 'organisation',
66 'association', 'society', 'chapter', 'troop', 'league',
67 'team', 'sports', 'sport', 'community', 'community center',
68 ],
69 ],
70 'environmental' => [
71 'slug' => 'environmental',
72 'label' => __( 'Environmental', 'better-payment' ),
73 'brief' => 'environmental',
74 'photo' => 'environmental/Tree-Plantation-Campaign.webp',
75 'keywords' => [
76 'environment', 'environmental', 'climate', 'conservation',
77 'sustainability', 'recycling', 'tree', 'trees', 'plantation',
78 'reforestation', 'forest', 'wildlife', 'ocean',
79 // Disaster relief lives under Environmental here because that
80 // is where its prebuilt template is tagged (disaster-relief).
81 'disaster', 'disaster relief', 'flood', 'earthquake',
82 'hurricane', 'wildfire', 'cyclone', 'tsunami',
83 ],
84 ],
85 'medical' => [
86 'slug' => 'medical',
87 'label' => __( 'Medical', 'better-payment' ),
88 'brief' => 'medical',
89 'photo' => 'medical/Medical-Emergency-Fund.webp',
90 'keywords' => [
91 'medical', 'medicine', 'health', 'healthcare', 'hospital',
92 'surgery', 'treatment', 'cancer', 'illness', 'disease',
93 'patient', 'clinic', 'therapy', 'transplant', 'diagnosis',
94 'icu', 'emergency',
95 ],
96 ],
97 'education' => [
98 'slug' => 'education',
99 'label' => __( 'Youth / Education', 'better-payment' ),
100 'brief' => 'education',
101 'photo' => 'education/Student-Success-Fund.webp',
102 'keywords' => [
103 'education', 'educational', 'school', 'student', 'students',
104 'college', 'university', 'scholarship', 'tuition',
105 'classroom', 'library', 'teacher', 'learning', 'books',
106 'youth', 'kids', 'children',
107 ],
108 ],
109 ];
110 }
111
112 /**
113 * Every campaign category, keyed by slug.
114 *
115 * Mirrors `better_payment/campaign_templates`: an add-on registering
116 * templates with a new tag should register the matching category here, so it
117 * gets a picker sidebar entry and a wizard tile instead of being reachable
118 * only via search. (The `photo` feeds the prebuilt-template artwork and the
119 * client tiles; AI-generated campaigns no longer use it — every generated
120 * photo uses `ai-default-hero.svg`. See `CampaignGenerator::default_photo_url`.)
121 *
122 * @return array<string, array>
123 */
124 public static function get_all(): array {
125 $defaults = self::defaults();
126
127 /**
128 * Filter the campaign category taxonomy.
129 *
130 * @param array<string, array> $categories Keyed by slug.
131 */
132 // @var is deliberate: the @param above describes what we pass in, but a
133 // third-party listener can return anything at all.
134 /** @var mixed $filtered */
135 $filtered = apply_filters( 'better_payment/campaign_categories', $defaults );
136
137 // An add-on returning a non-array must not take the built-in categories
138 // down with it (which would empty the picker sidebar AND the wizard's
139 // tiles), nor fatal the `: array` return — keep the unfiltered set.
140 return is_array( $filtered ) ? $filtered : $defaults;
141 }
142
143 /**
144 * The categories as a plain list, shaped for localization to the builder.
145 *
146 * `keywords` and the relative `photo` path are dropped — the client only
147 * needs to render tiles and echo a slug back. `photo` is resolved to an
148 * absolute URL so a caller never has to know the assets layout.
149 *
150 * @return array<int, array{slug: string, label: string, brief: string, photo: string}>
151 */
152 public static function for_client(): array {
153 $out = [];
154 foreach ( self::get_all() as $slug => $category ) {
155 $out[] = [
156 'slug' => (string) $slug,
157 'label' => (string) ( $category['label'] ?? $slug ),
158 'brief' => (string) ( $category['brief'] ?? $slug ),
159 'photo' => self::photo_url( (string) $slug ),
160 ];
161 }
162 return $out;
163 }
164
165 /**
166 * Whether a slug is a known category.
167 */
168 public static function exists( string $slug ): bool {
169 return '' !== $slug && isset( self::get_all()[ $slug ] );
170 }
171
172 /**
173 * Absolute URL of a category's hero image, or '' when the category is
174 * unknown, declares no photo, or the assets constant is missing.
175 *
176 * A `photo` that already looks absolute (an add-on pointing at its own
177 * plugin) is returned untouched; a relative one resolves against Lite's
178 * campaign-template assets, the same base TemplateManager uses.
179 */
180 public static function photo_url( string $slug ): string {
181 $categories = self::get_all();
182 $photo = isset( $categories[ $slug ]['photo'] ) ? (string) $categories[ $slug ]['photo'] : '';
183 if ( '' === $photo ) {
184 return '';
185 }
186
187 if ( 0 === strpos( $photo, 'http://' ) || 0 === strpos( $photo, 'https://' ) || 0 === strpos( $photo, '//' ) ) {
188 return $photo;
189 }
190
191 if ( ! defined( 'BETTER_PAYMENT_ASSETS' ) ) {
192 return '';
193 }
194
195 return BETTER_PAYMENT_ASSETS . '/img/campaign/templates/' . ltrim( $photo, '/' );
196 }
197
198 /**
199 * Infer a category from free text (an AI brief, a prompt, a title).
200 *
201 * This is the "or the AI picked it" half of category detection: the wizard
202 * sends an explicit slug only when the user clicks a category tile, so every
203 * other route in — the wizard's non-category causes, a hand-written prompt,
204 * a chat message — arrives as prose and is matched here.
205 *
206 * Keywords match on word boundaries, so "ngo" does not fire inside "among"
207 * and "tree" does not fire inside "street". The category with the most
208 * distinct keyword hits wins; ties break toward declaration order, keeping
209 * the result deterministic for a given text.
210 *
211 * @param string $text Free text to classify.
212 * @return string A category slug, or '' when nothing matches (a "new"
213 * category — the caller should fall back to the default).
214 */
215 public static function match_text( string $text ): string {
216 $text = trim( $text );
217 if ( '' === $text ) {
218 return '';
219 }
220
221 $best = '';
222 $score = 0;
223
224 foreach ( self::get_all() as $slug => $category ) {
225 $keywords = isset( $category['keywords'] ) && is_array( $category['keywords'] )
226 ? $category['keywords']
227 : [];
228
229 $hits = 0;
230 foreach ( $keywords as $keyword ) {
231 $keyword = trim( (string) $keyword );
232 if ( '' === $keyword ) {
233 continue;
234 }
235 if ( preg_match( '/\b' . preg_quote( $keyword, '/' ) . '\b/i', $text ) ) {
236 $hits++;
237 }
238 }
239
240 // Strictly greater, so the first-declared category wins a tie.
241 if ( $hits > $score ) {
242 $best = (string) $slug;
243 $score = $hits;
244 }
245 }
246
247 return $best;
248 }
249 }
250