PageSourceSearch

https://healthylifenetwork.com.au/assets/controllers/ads-campaign-wizard_controller-CMteIP4.js

js healthylifenetwork.com.au collected 2026-09-25 03:33:51 UTC 13,940 bytes, 329 lines download raw bytes

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.