1import { Controller } from '@hotwired/stimulus'; 2 3/** 4 * 3-step AI Campaign Wizard. 5 * 6 * Step 1 â Brief: account + brief text + landing URL 7 * Step 2 â Generate: calls /ads/campaigns/ai/generate, fills editable fields 8 * Step 3 â Review: platform-specific options + final submit 9 */ 10export default class extends Controller { 11 static targets = [ 12 'step', // all .wizard-step panels 13 'stepIndicator', // the <ul class="steps"> li items (for active class) 14 'account', // account <select> 15 'platform', // hidden input carrying inferred platform 16 'brief', // <textarea> campaign brief 17 'campaignTypePref', // step-1 campaign type picker (auto / SEARCH / VIDEO / â¦) 18 'landingUrl', // landing URL input 19 'generateBtn', // "Generate draft" button 20 'spinner', // loading spinner inside generate btn 21 'errorBox', // error banner in step 2 22 'contextNote', // "pulled N live campaigns as context" note 23 'rationale', // rationale paragraph 24 'campaignName', // editable campaign name input 25 'channelType', // channel type select 26 'headlines', // container div for headline inputs 27 'descriptions', // container div for description inputs 28 'keywords', // <textarea> for keywords 29 'dailyBudget', // daily budget input 30 'googleSection', // Google Search-only ad group + keywords section 31 'youtubeSection', // YouTube/VIDEO-only section (video URL + final URL) 32 'youtubeUrl', // YouTube video URL input (Step 2, has name attr) 33 'youtubeFinalUrl', // YouTube final URL input (Step 2, has name attr) 34 'youtubeHint', // Step 1 hint section â shown when VIDEO type is picked 35 'youtubeUrlHint', // Step 1 hint URL input (no name, syncs to youtubeUrl) 36 'youtubeFinalUrlHint', // Step 1 hint final URL input (syncs to youtubeFinalUrl) 37 'metaBiddingRow', // Meta has no bidding row â hide it 38 'reviewAccount', // Step 3 review summary cells 39 'reviewName',
40 'reviewType', 41 'reviewBudget', 42 'reviewHeadlines', 43 'reviewYoutubeUrl', 44 ]; 45 46 static values = { 47 generateUrl: String, 48 csrfToken: String, 49 }; 50 51 currentStep = 1; 52 53 connect() { 54 this.showStep(1); 55 this.updatePlatform(); 56 } 57 58 // ââ Step navigation ââââââââââââââââââââââââââââââââââââââââââââââââââ 59 60 next() { 61 if (this.currentStep === 1 && !this.validateStep1()) return; 62 if (this.currentStep < 3) this.showStep(this.currentStep + 1); 63 } 64 65 back() { 66 if (this.currentStep > 1) this.showStep(this.currentStep - 1); 67 } 68 69 // Skip the AI step: jump to Step 2 with blank, editable fields. Only an ad 70 // account is required (no brief). Carries the Step-1 type pick into Step 2. 71 manualSetup() { 72 this.hideError(); 73 if (!this.accountTarget.value) { 74 this.showStepError('Choose an ad account before continuing.'); 75 return; 76 } 77 if (this.hasCampaignTypePrefTarget && this.campaignTypePrefTarget.value !== 'auto' && this.hasChannelTypeTarget) { 78 this.channelTypeTarget.value = this.campaignTypePrefTarget.value; 79 } 80 this.toggleGoogleSection(); 81 this.showStep(2); 82 } 83 84 showStep(n) { 85 this.currentStep = n; 86 this.stepTargets.forEach((el, i) => { 87 el.classList.toggle('hidden', i + 1 !== n); 88 }); 89 this.stepIndicatorTargets.forEach((el, i) => { 90 el.classList.toggle('step-primary', i + 1 <= n); 91 }); 92 } 93 94 // ââ Platform awareness âââââââââââââââââââââââââââââââââââââââââââââââ 95 96 updatePlatform() { 97 const option = this.accountTarget.selectedOptions[0]; 98 const platform = option ? (option.dataset.platform ?? '') : ''; 99 this.platformTarget.value = platform; 100 101 const isGoogle = platform === 'google'; 102 const isMeta = platform === 'meta'; 103 104 // Channel type: swap optgroups visibility by disabling out-of-platform options 105 this.channelTypeTarget.querySelectorAll('optgroup').forEach(og => { 106 og.disabled = (og.label === 'Google' && !isGoogle) || (og.label === 'Meta' && !isMeta); 107 }); 108 // Select a sensible default for the active platform 109 if (isGoogle) this.channelTypeTarget.value = 'SEARCH'; 110 if (isMeta) this.channelTypeTarget.value = 'OUTCOME_TRAFFIC'; 111 112 // Step-1 campaign-type picker: mirror the platform gating; reset to "auto" 113 // if the current pick belongs to the other platform. 114 if (this.hasCampaignTypePrefTarget) { 115 this.campaignTypePrefTarget.querySelectorAll('optgroup').forEach(og => { 116 og.disabled = (og.label === 'Google' && !isGoogle) || (og.label === 'Meta' && !isMeta); 117 }); 118 const sel = this.campaignTypePrefTarget.selectedOptions[0]; 119 if (sel && sel.parentElement && sel.parentElement.disabled) { 120 this.campaignTypePrefTarget.value = 'auto'; 121 } 122 } 123 124 this.toggleGoogleSection(); 125 126 if (this.hasMetaBiddingRowTarget) { 127 this.metaBiddingRowTarget.classList.toggle('hidden', isMeta); 128 } 129 } 130 131 toggleGoogleSection() { 132 const isGoogle = this.platformTarget.value === 'google'; 133 const type = this.channelTypeTarget.value; 134 const isSearch = type === 'SEARCH'; 135 const isVideo = type === 'VIDEO'; 136 137 // Google Search: ad group + keywords section 138 this.googleSectionTargets.forEach(el => 139 el.classList.toggle('hidden', !(isGoogle && isSearch)) 140 ); 141 142 // Google VIDEO: YouTube URL section 143 this.youtubeSectionTargets.forEach(el => 144 el.classList.toggle('hidden', !(isGoogle && isVideo)) 145 ); 146 } 147 148 onTypePrefChange() { 149 this.updatePlatform(); 150 const isVideo = this.hasCampaignTypePrefTarget && 151 this.campaignTypePrefTarget.value === 'VIDEO';
152 this.youtubeHintTargets.forEach(el => el.classList.toggle('hidden', !isVideo)); 153 } 154 155 syncYoutubeHint() { 156 if (this.hasYoutubeUrlTarget && this.hasYoutubeUrlHintTarget) { 157 this.youtubeUrlTarget.value = this.youtubeUrlHintTarget.value; 158 } 159 if (this.hasYoutubeFinalUrlTarget && this.hasYoutubeFinalUrlHintTarget) { 160 this.youtubeFinalUrlTarget.value = this.youtubeFinalUrlHintTarget.value; 161 } 162 } 163 164 // ââ Validation âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 165 166 validateStep1() { 167 const brief = this.briefTarget.value.trim(); 168 if (!this.accountTarget.value) { 169 this.showStepError('Choose an ad account before continuing.'); return false; 170 } 171 if (brief.length < 20) { 172 this.showStepError('Describe your campaign goal in at least 20 characters.'); return false; 173 } 174 return true; 175 } 176 177 showStepError(msg) { 178 if (this.hasErrorBoxTarget) { 179 this.errorBoxTarget.textContent = msg; 180 this.errorBoxTarget.classList.remove('hidden'); 181 } else { 182 console.error('[campaign-wizard]', msg); 183 } 184 } 185 186 hideError() { 187 if (this.hasErrorBoxTarget) this.errorBoxTarget.classList.add('hidden'); 188 } 189 190 // ââ AI generation ââââââââââââââââââââââââââââââââââââââââââââââââââââ 191 192 async generate(event) { 193 event.preventDefault(); 194 this.hideError(); 195 196 if (!this.validateStep1()) return; 197 198 this.generateBtnTarget.disabled = true; 199 if (this.hasSpinnerTarget) this.spinnerTarget.classList.remove('hidden'); 200 201 const body = new URLSearchParams({ 202 _token: this.csrfTokenValue, 203 account: this.accountTarget.value, 204 brief: this.briefTarget.value.trim(), 205 landing_url: this.hasLandingUrlTarget ? this.landingUrlTarget.value.trim() : '', 206 channel_type_pref: this.hasCampaignTypePrefTarget ? this.campaignTypePrefTarget.value : 'auto', 207 }); 208 209 try { 210 const resp = await fetch(this.generateUrlValue, { 211 method: 'POST', 212 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 213 body: body.toString(), 214 }); 215 216 let json; 217 try { 218 json = await resp.json(); 219 } catch { 220 const detail = resp.status === 504 221 ? 'The AI took too long â please try again in a moment.' 222 : `Server error (HTTP ${resp.status}) â please try again.`; 223 this.showStepError(detail); 224 return; 225 } 226 227 if (!resp.ok || json.error) { 228 this.showStepError(json.error ?? 'AI generation failed. Please try again.'); 229 return; 230 } 231 232 this.fillDraft(json.draft); 233 this.showContextNote(json.context); 234 this.showStep(2); 235 } catch (e) { 236 this.showStepError(e instanceof TypeError ? 'Network error â check your connection.' : String(e.message)); 237 } finally { 238 this.generateBtnTarget.disabled = false; 239 if (this.hasSpinnerTarget) this.spinnerTarget.classList.add('hidden'); 240 } 241 } 242 243 fillDraft(draft) { 244 if (this.hasCampaignNameTarget) this.campaignNameTarget.value = draft.campaign_name ?? ''; 245 if (this.hasDailyBudgetTarget) this.dailyBudgetTarget.value = draft.daily_budget ?? '20.00'; 246 if (this.hasChannelTypeTarget) this.channelTypeTarget.value = draft.channel_type ?? 'SEARCH'; 247 if (this.hasRationaleTarget) this.rationaleTarget.textContent = draft.rationale ?? ''; 248 249 this.fillRepeatableInputs(this.headlinesTarget, draft.headlines ?? [], 'rsa_headlines[]', 30); 250 this.fillRepeatableInputs(this.descriptionsTarget, draft.descriptions ?? [], 'rsa_descriptions[]', 90); 251 252 if (this.hasKeywordsTarget && Array.isArray(draft.keywords)) { 253 this.keywordsTarget.value = draft.keywords.join('\n'); 254 } 255 256 this.toggleGoogleSection(); 257 } 258 259 showContextNote(context) { 260 if (!this.hasContextNoteTarget || !context) return; 261 const n = context.prior_campaigns ?? 0; 262 let msg; 263 if (context.live_pull && n > 0) { 264 msg = `Pulled ${n} live campaign${n === 1 ? '' : 's'} from the platform as context.`; 265 } else if (context.live_pull) { 266 msg = 'Pulled live from the platform â no existing campaigns on this account.'; 267 } else if (n > 0) { 268 msg = `Couldn't reach the platform â used ${n} previously synced campaign${n === 1 ? '' : 's'} as context.`; 269 } else { 270 msg = 'No prior campaigns available for context â generated from your brief alone.'; 271 } 272 const span = this.contextNoteTarget.querySelector('[data-context-text]'); 273 if (span) span.textContent = msg; 274 this.contextNoteTarget.classList.remove('hidden'); 275 this.contextNoteTarget.classList.add('flex'); 276 } 277 278 fillRepeatableInputs(container, values, name, maxLen) { 279 if (!container) return; 280 const inputs = container.querySelectorAll('input[type="text"]');
281 inputs.forEach((inp, i) => { 282 inp.value = (values[i] ?? '').substring(0, maxLen); 283 }); 284 } 285 286 // ââ Proceed to step 3 ââââââââââââââââââââââââââââââââââââââââââââââââ 287 288 toReview() { 289 this.populateReview(); 290 this.showStep(3); 291 } 292 293 populateReview() { 294 const opt = this.accountTarget.selectedOptions[0]; 295 if (this.hasReviewAccountTarget) { 296 this.reviewAccountTarget.textContent = opt ? opt.textContent.trim() : 'â'; 297 } 298 if (this.hasReviewNameTarget && this.hasCampaignNameTarget) { 299 this.reviewNameTarget.textContent = this.campaignNameTarget.value.trim() || 'â'; 300 } 301 if (this.hasReviewTypeTarget && this.hasChannelTypeTarget) { 302 const sel = this.channelTypeTarget.selectedOptions[0]; 303 this.reviewTypeTarget.textContent = sel ? sel.textContent.trim() : 'â'; 304 } 305 if (this.hasReviewBudgetTarget && this.hasDailyBudgetTarget) { 306 const val = parseFloat(this.dailyBudgetTarget.value || '0'); 307 this.reviewBudgetTarget.textContent = val > 0 ? ('$' + val.toFixed(2) + '/day') : 'â'; 308 } 309 if (this.hasReviewHeadlinesTarget && this.hasHeadlinesTarget) { 310 const hls = Array.from(this.headlinesTarget.querySelectorAll('input[type="text"]')) 311 .map(i => i.value.trim()).filter(Boolean).slice(0, 3); 312 this.reviewHeadlinesTarget.textContent = hls.length ? hls.join(' · ') : 'â'; 313 } 314 if (this.hasReviewYoutubeUrlTarget) { 315 const url = (this.hasYoutubeUrlHintTarget ? this.youtubeUrlHintTarget.value.trim() : '') 316 || (this.hasYoutubeUrlTarget ? this.youtubeUrlTarget.value.trim() : '') 317 || 'â'; 318 this.reviewYoutubeUrlTarget.textContent = url; 319 } 320 } 321 322 backToBrief() { 323 this.showStep(1); 324 } 325 326 backToGenerate() { 327 this.showStep(2); 328 } 329}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.