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