PageSourceSearch

https://app.knockknockapp.ai/assets/Settings-dFR3J7-p.js

js knockknockapp.ai collected 2026-09-24 13:18:51 UTC 62,477 bytes, 445 lines download raw bytes

1import{l as n,m as d,J as r,K as s,p as _,B as o,z as M,F as E,v as N,q as t,u as a,P as Pe,w as Re,aa as g,ad as C,y as h,af as W,r as D,e as x,a6 as Ie,D as Oe,ac as P,ab as Le,ag as Ke}from"./vue-vendor-B52ivs5F.js";import{u as Ue,b as le,c as qe}from"./query-vendor-Ab0nfCYP.js";import{f as ze,g as Ne,t as je,o as Fe,s as y,c as G,r as Ve,h as K,l as de,b as De,F as Ge}from"./form-vendor-C1mcXLoR.js";import{c as $,t as ee,d as He}from"./index-NM9cAr_v.js";import{_ as Y}from"./MigrationPanel-CaVZK-wO.js";import{_ as k}from"./FormInput-D_5KVMuV.js";import{a5 as ae,ak as re,L as pe,ai as Me,cN as Be,az as We,b as Ye,i as U,y as q,a6 as Je,a7 as Qe,I as Xe}from"./ui-vendor-D-1UBobO.js";import{F as z}from"./FormNumberInput-BG6rQTcg.js";import"./phone-input-Cde_R06_.js";import{_ as Ze}from"./FormSelect-6HD9lt1G.js";import{_ as H}from"./FormSwitch-C1D9T4OV.js";import{_ as te}from"./FormTextarea-DyKq4Pyg.js";import"./FormTimePicker.vue_vue_type_style_index_1_lang-BaT8rZTb.js";import{g as et}from"./agency-invites-Dn8xsfYi.js";import{E as tt,F as at,l as rt,y as nt,G as ot,H as it,I as st,A as lt,J as dt}from"./certification-sNUmTbUZ.js";import"./date-vendor-HBGOQ70J.js";import"./twilio-DAuME2Y9.js";import"./charts-D4hDxUnJ.js";import"./socket-CzTGKfeH.js";import"./ConfirmDialog-CguVS9_-.js";/* empty css                                                                        */const pt={class:"mb-4"},ct={key:0,class:"mb-1 block text-sm font-medium text-gray-700 dark:text-gray-200"},ht={key:0,class:"text-red-500"},gt=["title","onClick"],ut={key:0,class:"mb-2 text-sm text-gray-400"},mt=["onClick"],yt={key:1,class:"mt-2 text-sm text-red-500"},bt={key:2,class:"mt-2 text-sm text-gray-500 dark:text-gray-400"},_t={__name:"FormEmailList",props:{name:{type:String,required:!0},label:{type:String,default:""},hint:{type:String,default:""},placeholder:{type:String,default:"[email protected]"},addLabel:{type:String,default:"Add another"},firstLabel:{type:String,default:"Add an address"},emptyText:{type:String,default:"Nobody added yet."},required:{type:Boolean,default:!1}},setup(f){const T=ze(Pe(f,"name"));return(S,J)=>(n(),d("div",pt,[f.label?(n(),d("label",ct,[r(s(f.label)+" ",1),f.required?(n(),d("span",ht,"*")):_("",!0)])):_("",!0),o(a(Ne),{name:f.name},{default:M(({fields:w,push:A,remove:Q})=>[(n(!0),d(E,null,N(w,(R,B)=>(n(),d("div",{key:R.key,class:"mb-2 flex items-start gap-2"},[o(k,{name:`${f.name}[${B}]`,type:"email",placeholder:f.placeholder,class:"mb-0 flex-1"},null,8,["name","placeholder"]),t("button",{type:"button",title:`Remove ${R.value||"this address"}`,class:"mt-1 shrink-0 rounded-lg border border-gray-200 p-2 text-gray-400 transition-colors hover:border-red-200 hover:text-red-600 dark:border-gray-600 dark:hover:border-red-800",onClick:X=>Q(B)},[o(a(ae),{size:15})],8,gt)]))),128)),w.length?_("",!0):(n(),d("p",ut,s(f.emptyText),1)),t("button",{type:"button",class:"inline-flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700",onClick:R=>A("")},[o(a(re),{size:15}),r(" "+s(w.length?f.addLabel:f.firstLabel),1)],8,mt)]),_:1},8,["name"]),a(T)?(n(),d("p",yt,s(a(T)),1)):f.hint?(n(),d("p",bt,s(f.hint),1)):_("",!0)]))}},ft={class:"p-6"},vt={key:0,class:"py-16 text-center"},kt={class:"mb-4 flex flex-wrap items-center gap-1 border-b border-gray-200 dark:border-gray-800"},xt=["onClick"],wt={class:"flex items-start gap-2"},$t={class:"flex-1 space-y-2"},Tt=["onUpdate:modelValue"],St={class:"flex flex-wrap items-center gap-2"},At=["onUpdate:modelValue"],Et=["value"],Ct=["onUpdate:modelValue"],Pt={class:"flex shrink-0 cursor-pointer items-center gap-2 text-sm text-gray-600 dark:text-gray-400"},Rt=["onUpdate:modelValue"],It={key:0,class:"flex flex-wrap items-center gap-2"},Ot={class:"flex shrink-0 items-center gap-2 text-sm text-gray-600 dark:text-gray-400"},Lt=["onUpdate:modelValue","placeholder"],Kt={class:"flex shrink-0 items-center gap-2 text-sm text-gray-600 dark:text-gray-400"},Ut=["onUpdate:modelValue","placeholder"],qt=["onClick"],zt={class:"mb-3 flex items-start gap-3"},Nt={class:"mt-2 text-sm font-medium text-gray-400"},jt=["onUpdate:modelValue"],Ft=["onClick"],Vt={class:"flex flex-wrap items-center gap-2 pl-7"},Dt=["onUpdate:modelValue"],Gt=["onUpdate:modelValue"],Ht={class:"mt-2 pl-7 text-xs text-gray-500 dark:text-gray-400"},Mt={class:"mb-4 text-sm text-gray-500 dark:text-gray-400"},Bt={class:"mb-4 text-sm text-gray-500 dark:text-gray-400"},Wt={class:"mt-6 border-t border-gray-200 pt-6 dark:border-gray-700"},Yt={class:"grid gap-x-6 sm:grid-cols-2"},Jt={class:"grid gap-x-6 sm:grid-cols-2"},Qt=["disabled"],Xt={key:4,class:"max-w-3xl rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"},Zt={class:"mt-3 flex flex-wrap items-center gap-3 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/50"},ea={class:"mt-5 rounded-lg border border-gray-200 p-4 dark:border-gray-700"},ta={class:"mt-2 flex flex-wrap items-center gap-2"},aa={class:"flex-1 rounded-lg bg-gray-100 px-3 py-2 font-mono text-xs break-all text-gray-800 dark:bg-gray-900 dark:text-gray-200"}
1,ra={key:0,class:"text-amber-600 dark:text-amber-400"},na={key:0,class:"mt-2 rounded-lg border border-amber-200 bg-amber-50 p-2.5 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"},oa={class:"mt-5"},ia={class:"mb-3 flex flex-wrap items-center gap-2"},sa={class:"mt-6 rounded-lg border border-gray-200 p-4 dark:border-gray-700"},la={class:"mb-3 text-sm text-gray-600 dark:text-gray-400"},da={class:"mb-3 flex flex-wrap items-center gap-2"},pa={class:"mt-6 border-t border-gray-200 pt-5 dark:border-gray-700"},ca={class:"mb-3 flex flex-wrap items-center gap-2"},ha={class:"mt-6 border-t border-gray-200 pt-5 dark:border-gray-700"},ga={class:"mb-3 flex flex-wrap items-center gap-2"},ua={class:"overflow-x-auto rounded-lg bg-gray-100 p-3 font-mono text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-200"},ce=`{
2  "valid": true,
3  "code": "KK-7F3K-2QD8",
4  "cohort": { "number": 3, "name": "Cohort Three" },
5  "invited_email": "[email protected]",   // null on an unbound code
6  "invited_name": "Ayesha Alam"
7}
8
9// or, for anything not spendable:
10{
11  "valid": false,
12  "message": "That invite code has already been used. Each code works for one person only."
13}`,he=`{
14  "results": [
15    {
16      "slug": "ayesha-alam",
17      "name": "Ayesha Alam",
18      "initials": "AA",
19      "avatar_color": "#2E9C8A",
20      "photo_file_id": "68f1…",       // null when no photo uploaded
21      "tagline": "Recruitment agency automation",
22      "bio": "Onboarding automations for recruitment agencies.",
23      "city": "Karachi",
24      "country_code": "PK",
25      "country": "Pakistan",
26      "tags": ["Onboarding Flows", "GHL Subaccounts"],
27      "whatsapp": "923001234567",     // digits only, for wa.me
28      "website": "",
29      "cohort": 1,
30      "cohort_name": "Cohort One",
31      "certified_at": "2026-09-22T00:00:00.000Z",
32      "certificate_id": "KKCIP-2026-77CDC9"
33    }
34  ],
35  "total": 1,
36  "countries": [{ "code": "PK", "name": "Pakistan", "count": 1 }]
37}`,ge=`{
38  "cohort": { "number": 3, "name": "Cohort Three", ... },
39  "cohorts": [
40    {
41      "number": 3,
42      "name": "Cohort Three",
43      "starts_on": "2026-09-14T00:00:00.000Z",
44      "ends_on": "2026-09-18T00:00:00.000Z",
45      "seats_total": 40,
46      "seats_left": 37,
47      "applications_count": 23,
48      "applications_open": true,
49      "applications_close_on": "2026-09-09"
50    },
51    {
52      "number": 2,
53      "name": "Founding",
54      "starts_on": null,
55      "ends_on": "2026-09-30T00:00:00.000Z",
56      "seats_total": 50,
57      "seats_left": 43,
58      "applications_count": 0,
59      "applications_open": true,
60      "applications_close_on": "2026-09-09"
61    }
62  ]
63}`,ue=`// queued for review
64{
65  "status": "pending",
66  "message": "Thanks — your application is in. You'll get a real answer either way."
67}
68
69// applied with a partner's invite code
70{
71  "status": "accepted",
72  "message": "You're in — finish setting up your account to start.",
73  "code": "KK-4H7P-QW2N",
74  "register_url": "https://app.knockknockapp.ai/register?invite=KK-4H7P-QW2N&email=..."
75}`,Ua={__name:"Settings",setup(f){const ne=Ue(),T=`${$.getApiUrl()}/certification/open-cohort`,S=`${$.getApiUrl()}/public/partners`,J=`${$.getApiUrl()}/files/<photo_file_id>/get`,w=`${$.getApiUrl()}/certification/invite/<code>`,A=`${$.getApiUrl()}/certification/apply`,Q=x(()=>i.value?.register_url||`${window.location.origin}/register`),R=x(()=>`<?php
76/**
77 * Invite landing page. Publish at /join, then set it as the invite landing page
78 * in Certification Settings.
79 *
80 * Returns an error string to render, or redirects and never comes back.
81 */
82function kk_handle_invite() {
83    $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : '';
84    if ( '' === $code ) {
85        return 'That link is missing its invite code.';
86    }
87
88    $res = wp_remote_get(
89        '${$.getApiUrl()}/certification/invite/' . rawurlencode( $code ),
90        array(
91            'timeout' => 5,
92            'headers' => array( 'X-Partner-Key' => KK_PARTNER_KEY ),
93        )
94    );
95
96    if ( is_wp_error( $res ) || 200 !== wp_remote_retrieve_response_code( $res ) ) {
97        return "We couldn't check that invite just now. Please try again shortly.";
98    }
99
100    $body = json_decode( wp_remote_retrieve_body( $res ), true );
101
102    if ( empty( $body['valid'] ) ) {
103        // Already written for the invitee — say exactly this.
104        return $body['message'];
105    }
106
107    // Good code: hand over what the sign-up form needs to greet them. None of it
108    // is trusted over there; the code is checked again when the form is submitted.
109    $to = add_query_arg(
110        array_filter(
111            array(
112                'invite' => $body['code'],
113                'email'  => $body['invited_email'],
114                'name'   => $body['invited_name'],
115                'cohort' => isset( $body['cohort']['name'] ) ? $body['cohort']['name'] : '',
116            )
117        ),
118        '${Q.value}'
119    );
120
121    // Fixed destination, not user input, so wp_redirect is the right call here.
122    wp_redirect( $to );
123    exit;
124}`),B=x(()=>`<?php
125/**
126 * Handles the apply form. Hook it to admin_post_nopriv_kk_apply and
127 * admin_post_kk_apply, and point the form's action at admin_post.php.
128 */
129function kk_handle_apply() {
130    check_admin_referer( 'kk_apply' );
131
132    $payload = array(
133        'name'          => sanitize_text_field( $_POST['name'] ?? '' ),
134        'email'         => sanitize_email( $_POST['email'] ?? '' ),
135        'country'       => sanitize_text_field( $_POST['country'] ?? '' ),
136        'timezone'      => sanitize_text_field( $_POST['timezone'] ?? '' ),
137        // Must be one of the listed keys exactly, or the request is rejected.
138        'ghl_accounts'  => sanitize_text_field( $_POST['ghl_accounts'] ?? '' ),
139        'relationship'  => sanitize_text_field( $_POST['relationship'] ?? '' ),
140        'commitment'    => sanitize_text_field( $_POST['commitment'] ?? '' ),
141        'first_client'  => sanitize_textarea_field( $_POST['first_client'] ?? '' ),
142        // Optional. A valid one is accepted on the spot and skips the queue.
143        'code'          => sanitize_text_field( $_POST['code'] ?? '' ),
144    );
145
146    $res = wp_remote_post(
147        '${A}',
148        array(
149            'timeout' => 10,
150            'headers' => array(
151                'X-Partner-Key' => KK_PARTNER_KEY,
152                'Content-Type'  => 'application/json',
153            ),
154            'body'    => wp_json_encode( $payload ),
155        )
156    );
157
158    if ( is_wp_error( $res ) ) {
159        wp_safe_redirect( add_query_arg( 'kk_error', rawurlencode( 'Something went wrong — please try again.' ), wp_get_referer() ) );
160        exit;
161    }
162
163    $body = json_decode( wp_remote_retrieve_body( $res ), true );
164
165    // 400 carries a message written for the applicant — a bad invite code, a
166    // closed round. Show it as-is rather than inventing your own wording.
167    if ( 200 !== wp_remote_retrieve_response_code( $res ) ) {
168        wp_safe_redirect( add_query_arg( 'kk_error', rawurlencode( $body['message'] ?? 'Please check your answers.' ), wp_get_referer() ) );
169        exit;
170    }
171
172    // Accepted means they came in with a code and their seat is real. Send them
173    // straight to sign-up; anything else is a step they did not ask for.
174    if ( 'accepted' === ( $body['status'] ?? '' ) && ! empty( $body['register_url'] ) ) {
175        wp_redirect( $body['register_url'] );
176        exit;
177    }
178
179    wp_safe_redirect( add_query_arg( 'kk_applied', '1', wp_get_referer() ) );
180    exit;
181}
182add_action( 'admin_post_nopriv_kk_apply', 'kk_handle_apply' );
183add_action( 'admin_post_kk_apply', 'kk_handle_apply' );`),X=`<?php
184// wp-config.php — kept out of the theme and out of version control.
185// define( 'KK_PARTNER_KEY', 'your-key-here' );
186
187/**
188 * Certified partners, cached. Never throws and never returns null — a directory
189 * that renders stale is better than one that renders empty.
190 */
191function kk_partners() {
192    $cached = get_transient( 'kk_partners' );
193    if ( false !== $cached ) {
194        return $cached;
195    }
196
197    $res = wp_remote_get(
198        '${$.getApiUrl()}/public/partners',
199        array(
200            'timeout' => 5,
201            // Defined in wp-config.php, so the key never lands in the theme or
202            // in version control.
203            'headers' => array( 'X-Partner-Key' => KK_PARTNER_KEY ),
204        )
205    );
206
207    if ( is_wp_error( $res ) || 200 !== wp_remote_retrieve_response_code( $res ) ) {
208        // Timeout, 429 or an outage: fall back to the last good copy.
209        return get_option( 'kk_partners_fallback', array() );
210    }
211
212    $body     = json_decode( wp_remote_retrieve_body( $res ), true );
213    $partners = isset( $body['results'] ) ? $body['results'] : array();
214
215    set_transient( 'kk_partners', $partners, 5 * MINUTE_IN_SECONDS );
216    // Outlives the transient, so there is always something to fall back to.
217    update_option( 'kk_partners_fallback', $partners, false );
218
219    return $partners;
220}
221
222/**
223 * Seats left in the open intake, or null when nothing is open.
224 */
225function kk_open_cohort() {
226    $cached = get_transient( 'kk_open_cohort' );
227    if ( false !== $cached ) {
228        return $cached ?: null;
229    }
230
231    $res = wp_remote_get(
232        '${$.getApiUrl()}/certification/open-cohort',
233        array(
234            'timeout' => 5,
235            'headers' => array( 'X-Partner-Key' => KK_PARTNER_KEY ),
236        )
237    );
238    if ( is_wp_error( $res ) || 200 !== wp_remote_retrieve_response_code( $res ) ) {
239        return null;
240    }
241
242    $body   = json_decode( wp_remote_retrieve_body( $res ), true );
243    $cohort = isset( $body['cohort'] ) ? $body['cohort'] : null;
244
245    // Cache the "nothing open" answer too, or a closed programme retries every hit.
246    set_transient( 'kk_open_cohort', $cohort ?: 0, 5 * MINUTE_IN_SECONDS );
247
248    return $cohort;
249}`,oe=`<?php foreach ( kk_partners() as $p ) : ?>
250  <article class="kk-card">
251    <div class="kk-card__in">
252      <?php if ( ! empty( $p['photo_file_id'] ) ) : ?>
253        <img class="kk-card__photo"
254             src="${$.getApiUrl()}/files/<?php echo esc_attr( $p['photo_file_id'] ); ?>/get"
255             alt="" loading="lazy">
256      <?php else : ?>
257        <span class="kk-card__avatar"
258              style="background:<?php echo esc_attr( $p['avatar_color'] ); ?>">
259          <?php echo esc_html( $p['initials'] ); ?>
260        </span>
261      <?php endif; ?>
262
263      <h2 class="kk-card__name"><?php echo esc_html( $p['name'] ); ?></h2>
264      <p class="kk-card__loc">
265        <?php echo esc_html( $p['city'] . ', ' . $p['country'] ); ?>
266      </p>
267      <p class="kk-card__bio"><?php echo esc_html( $p['bio'] ); ?></p>
268
269      <div class="kk-card__tags">
270        <?php foreach ( $p['tags'] as $tag ) : ?>
271          <span class="kk-card__tag"><?php echo esc_html( $tag ); ?></span>
272        <?php endforeach; ?>
273      </div>
274
275      <?php if ( ! empty( $p['whatsapp'] ) ) : ?>
276        <a class="kk-card__wa" rel="noopener nofollow" target="_blank"
277           href="https://wa.me/<?php echo esc_attr( $p['whatsapp'] ); ?>">WhatsApp</a>
278      <?php endif; ?>
279
280      <?php if ( ! empty( $p['website'] ) ) : ?>
281        <a class="kk-card__site" rel="noopener nofollow" target="_blank"
282           href="<?php echo esc_url( $p['website'] ); ?>">
283          <?php // Scheme and trailing slash are noise on a card. ?>
284          <?php echo esc_html( preg_replace( array( '#^https?://#i', '#/$#' ), '', $p['website'] ) ); ?>
285        </a>
286      <?php endif; ?>
287
288      <a class="kk-card__profile"
289         href="/partners/<?php echo esc_attr( $p['slug'] ); ?>/">Profile</a>
290    </div>
291  </article>
292<?php endforeach; ?>`,Z=x(()=>`# Knock Knock — Certified Partner Directory API
293
294Build a WordPress page that lists our certified implementation partners, and a
295second page for a single partner's profile.
296
297## Hard rules
298
2991. **Fetch server-side in PHP only** (\`wp_remote_get\`). Never call these from
300   browser JavaScript — the API key must never reach a visitor.
3012. **Cache every response** in a transient, and keep a longer-lived fallback copy.
302   On any failure, render the fallback rather than an empty directory.
3033. **Filter and search in the browser**, over the cards already rendered. The
304   \`?country=\` and \`?search=\` parameters exist, but calling the API on every
305   filter click wastes the rate limit and defeats server-side rendering.
3064. **Rate limit is 60 requests per minute per IP.** Every visitor shares the
307   WordPress server's one IP, so the cache is what keeps you under it. Over the
308   limit you get \`429\` with a \`Retry-After\` header.
3095. Escape everything on output (\`esc_html\`, \`esc_attr\`, \`esc_url\`). All of
310   this is partner-supplied text.
311
312## Authentication
313
314Every request sends:
315
316\`\`\`
317X-Partner-Key: YOUR_PARTNER_KEY
318\`\`\`
319
320Store it in \`wp-config.php\` as \`KK_PARTNER_KEY\` so it stays out of the theme
321and out of version control. Get the real value from Certification Settings →
322API in the Knock Knock admin panel — do not paste it into an AI tool.
323
324## Endpoints
325
326### GET ${S}
327Every published listing. Optional \`?country=<ISO code>\` and \`?search=<text>\`.
328
329\`\`\`json
330${he}
331\`\`\`
332
333### GET ${S}/<slug>
334One partner, same card shape, wrapped as \`{ "partner": { … } }\`. Returns 404
335for an unknown slug and for a listing that has been taken down.
336
337### GET ${T}
338The intake currently taking sign-ups, for a "N seats left" line.
339
340\`\`\`json
341${ge}
342\`\`\`
343
344\`cohorts\` is EVERY open intake, newest first; \`cohort\` is the one new
345sign-ups go to (the newest with a seat left), repeated for convenience.
346
347More than one intake can be open at once, which is how a page runs two panels:
348the round you apply to, and a named intake like \`"Founding"\` counting its own
349seats down beside it. Pick by \`name\` — each is an ordinary cohort with its own
350\`seats_left\` and its own \`ends_on\`, so "43 places left, closes 30 Sept" is
351just that cohort's two fields.
352
353Returns \`{ "cohort": null, "cohorts": [] }\` when nothing is open — hide the
354counters, don't print zeroes.
355
356\`applications_count\` is the "applications so far" number,
357\`applications_close_on\` the deadline to print, and \`applications_open\` is
358whether to render the form at all. All three are admin-controlled and can change
359between rounds, so read them rather than hard-coding the page.
360
361### POST ${A}
362One application. Send JSON.
363
364| Field | Required | Notes |
365| --- | --- | --- |
366| \`name\` | yes | |
367| \`email\` | yes | |
368| \`country\` | no | free text or ISO code |
369| \`timezone\` | no | free text, e.g. \`Asia/Karachi\` |
370| \`ghl_accounts\` | no | \`none\` \`1-3\` \`4-10\` \`10-plus\` |
371| \`relationship\` | no | \`freelancer\` \`own_agency\` \`in_house\` \`other\` |
372| \`commitment\` | no | \`live\` \`partial\` \`replays\` \`not_this_round\` |
373| \`first_client\` | no | free text, up to 2000 characters |
374| \`code\` | no | a partner's invite code — skips the queue |
375
376Only name and email are required; everything else is graded by a person, so a
377thin application is still worth taking. A value outside the lists above is
378rejected with 400 — send the exact keys, not the labels shown on your form.
379
380\`\`\`json
381${ue}
382\`\`\`
383
384\`status\` is \`pending\` for a normal application and \`accepted\` when a valid
385invite code was supplied. On \`accepted\`, redirect to \`register_url\` — the
386code is checked but not spent until they finish signing up.
387
388A 400 carries a \`message\` written for the applicant (bad code, closed round).
389Show it as-is. Applying twice with the same address in one round updates that
390application rather than making a second one.
391
392### GET ${w}
393Checks an invite code for the invite landing page. Always 200; \`valid\` is the
394answer and \`message\` is written for the invitee, so show it as-is.
395
396\`\`\`json
397${ce}
398\`\`\`
399
400## Field notes
401
402- \`countries\` carries the filter pills and is counted across the whole
403  directory, not the filtered slice, so the row never empties as 
403someone filters.
404- \`initials\`, \`avatar_color\` and \`country\` are derived server-side. Render
405  them; do not store or recompute them.
406- \`photo_file_id\` is null when the partner has no photo — fall back to the
407  initials chip on \`avatar_color\`. The image URL is
408  \`${J}\` and needs no key, so it can be linked straight from the page.
409- \`whatsapp\` is digits only, ready for \`https://wa.me/<number>\`. Empty when
410  the partner chose not to give one — hide the button then.
411- \`slug\` is frozen at first publish, so \`/partners/<slug>/\` URLs are stable
412  and safe to index.
413- \`cohort\` may be null for partners who predate intakes. Render without it.
414
415## Reference implementation
416
417\`\`\`php
418${X}
419\`\`\`
420
421## Rendering the cards
422
423\`\`\`php
424${oe}
425\`\`\`
426
427## Invite landing page
428
429A separate page that validates an invite code and forwards the invitee to
430sign-up. Publish it, then set its URL as the invite landing page in
431Certification Settings → Invites.
432
433\`\`\`php
434${R.value}
435\`\`\`
436
437## Apply page
438
439The application form. Renders only while \`applications_open\` is true, and
440posts through PHP like everything else here.
441
442\`\`\`php
443${B.value}
444\`\`\`
445`),v=x(()=>i.value?.partner_api_key||null),j=D(!1),me=x(()=>v.value?`${"•".repeat(Math.max(8,v.value.length-4))}${v.value.slice(-4)}`:""),u=D(null),I=async p=>{try{await navigator.clipboard.writeText(p),u.value=p,setTimeout(()=>{u.value===p&&(u.value=null)},2e3)}catch{ee.error("Could not copy")}},{data:i,isLoading:ye}=le({queryKey:["admin","certification","settings"],queryFn:lt,refetchOnMount:"always"}),be=[{key:"general",label:"General"},{key:"video",label:"Video Submission"},{key:"social",label:"Social Proof"},{key:"badge",label:"Marketplace Badge"},{key:"invites",label:"Invites"},{key:"applications",label:"Applications"},{key:"api",label:"API"}],m=D("general"),ie={enrollment_reward_usd:"general",max_file_mb:"general",accepted_file_types:"general",package_id:"general",agency_links_enabled:"general",agency_invite_id:"general",reviewer_emails:"general",step6_reward_usd:"social",social_heading:"social",step7_reward_usd:"badge",badge_platforms:"badge",partner_invite_quota:"invites",invite_expiry_days:"invites",invites_enabled:"invites",block_package_signup:"invites",signup_blocked_message:"invites",invite_landing_url:"invites",applications_enabled:"applications",applications_close_on:"applications",applications_closed_message:"applications",applicant_ghl_tag:"applications",stage_ghl_tags:"applications",cohort_ghl_tag_enabled:"applications",invite_code_ghl_field:"applications",blocked_ghl_tag:"applications",archived_ghl_tag:"applications"},_e=({errors:p})=>{const e=Object.keys(p||{})[0];if(!e)return;const O=e.replace(/\[\d+\].*$/,"");ie[O]&&(m.value=ie[O])},fe=je(Fe({enrollment_reward_usd:K.number().min(0,"Cannot be negative"),step6_reward_usd:K.number().min(0,"Cannot be negative"),step7_reward_usd:K.number().min(0,"Cannot be negative"),social_heading:y().optional(),badge_platforms:y().min(1,"List at least one platform"),max_file_mb:K.number().min(1,"Must be at least 1MB"),accepted_file_types:y().min(1,"List at least one file type"),package_id:y().optional(),agency_invite_id:y().optional(),agency_links_enabled:G().optional(),reviewer_emails:De(y().trim().email("Enter a valid email address").or(de(""))).optional(),invites_enabled:G(),block_package_signup:G(),signup_blocked_message:y().trim().min(1,"Tell them how to get an invite"),invite_landing_url:y().url("Enter a valid URL").optional().or(de("")),partner_invite_quota:K.number().int("Must be a whole number").min(0,"Cannot be negative"),invite_expiry_days:K.number().int("Must be a whole number").min(0,"Cannot be negative"),applications_enabled:G(),applications_close_on:y().optional(),applications_closed_message:y().optional(),applicant_ghl_tag:y().optional(),stage_ghl_tags:Ve(y()).optional(),cohort_ghl_tag_enabled:G(),invite_code_ghl_field:y().optional(),blocked_ghl_tag:y().optional(),archived_ghl_tag:y().optional()})),ve=x(()=>({enrollment_reward_usd:i.value?.enrollment_reward_usd??5,step6_reward_usd:i.value?.step6_reward_usd??25,step7_reward_usd:i.value?.step7_reward_usd??25,social_heading:i.value?.social_heading||"",badge_platforms:(i.value?.badge_platforms||["upwor
445k","fiverr"]).join(", "),max_file_mb:i.value?.max_file_mb??25,accepted_file_types:(i.value?.accepted_file_types||[]).join(", "),package_id:i.value?.package_id||"",agency_invite_id:i.value?.agency_invite_id||"",agency_links_enabled:i.value?.agency_links_enabled!==!1,reviewer_emails:String(i.value?.reviewer_emails||"").split(",").map(p=>p.trim()).filter(Boolean),partner_invite_quota:i.value?.partner_invite_quota??5,invite_expiry_days:i.value?.invite_expiry_days??30,invites_enabled:i.value?.invites_enabled!==!1,block_package_signup:i.value?.block_package_signup!==!1,signup_blocked_message:i.value?.signup_blocked_message||"",invite_landing_url:i.value?.invite_landing_url||"",applications_enabled:i.value?.applications_enabled!==!1,applications_close_on:String(i.value?.applications_close_on||"").slice(0,10),applications_closed_message:i.value?.applications_closed_message||"",applicant_ghl_tag:i.value?.applicant_ghl_tag??"",stage_ghl_tags:Object.fromEntries(se.map(({name:p,placeholder:e})=>[p,i.value?.stage_ghl_tags?.[p]??e])),cohort_ghl_tag_enabled:i.value?.cohort_ghl_tag_enabled!==!1,invite_code_ghl_field:i.value?.invite_code_ghl_field??"",blocked_ghl_tag:i.value?.blocked_ghl_tag??"",archived_ghl_tag:i.value?.archived_ghl_tag??""})),{data:ke}=le({queryKey:["admin","certification","agency-invites"],queryFn:et,staleTime:3e5}),xe=x(()=>[{value:"",label:"First active plan"},...(ke.value?.results||[]).map(p=>({value:p._id,label:p.status==="active"?p.name:`${p.name} (inactive)`}))]),se=[{name:"invited",label:"Invited",placeholder:"certification-invited",hint:"Emailed an invite code and has not signed up yet. Signing up moves them to Joined."},{name:"pending",label:"Applied",placeholder:"certification-applied",hint:"Submitted the apply page and waiting on a decision."},{name:"accepted",label:"Accepted",placeholder:"certification-accepted",hint:"Accepted from the applications queue and has not signed up yet. Signing up moves them to Joined."},{name:"joined",label:"Joined",placeholder:"certification-joined",hint:"Signed up with an invite code but has not pressed Enroll yet."},{name:"rejected",label:"Rejected",placeholder:"certification-rejected",hint:"Turned down for this round."},{name:"waitlisted",label:"Waitlisted",placeholder:"certification-waitlisted",hint:"Held for a later intake."},{name:"enrolled",label:"Enrolled",placeholder:"certification-enrolled",hint:"Signed up and pressed Enroll — the programme has actually started."},{name:"certified",label:"Certified",placeholder:"certification-certified",hint:"Finished it and holds the certificate."}],we=[{value:"text",label:"Text"},{value:"link",label:"Link"},{value:"file",label:"File upload"}],$e=x(()=>i.value?.max_file_mb||25),Te=x(()=>(i.value?.accepted_file_types||[]).join(", ")),F=D([]),V=D([]);Re(i,p=>{F.value=(p?.video_fields||[]).map(e=>({id:e.id,label:e.label,type:e.type||"text",required:e.required!==!1,hint:e.hint||"",max_file_mb:e.max_file_mb??null,accepted_types:(e.accepted_types||[]).join(", ")})),V.value=(p?.social_cards||[]).map(e=>({id:e.id,heading:e.heading||"",link_label:e.link_label||"",link_url:e.link_url||""}))},{immediate:!0});const Se=()=>{V.value.push({id:"",heading:"",link_label:"",link_url:""})},Ae=()=>{F.value.push({id:"",label:"",type:"text",required:!0,hint:"",max_file_mb:null,accepted_types:""})},Ee=qe({mutationFn:dt,onSuccess:()=>{ee.success("Settings saved"),ne.invalidateQueries({queryKey:["admin","certification","settings"]})},onError:p=>ee.error(He(p,"Could not save settings"))}),Ce=async p=>{await Ee.mutateAsync({...p,reviewer_emails:(p.reviewer_emails||[]).map(e=>String(e).trim()).filter(Boolean),video_fields:F.value.map(e=>({...e,max_file_mb:e.type==="file"&&e.max_file_mb||null,accepted_types:e.type==="file"?e.accepted_types||"":[]})),social_cards:V.value})};return(p,e)=>{const O=Ie("RouterLink");return n(),d("div",ft,[e[62]||(e[62]=t("div",{class:"mb-6"},[t("h1",{class:"text-2xl font-bold text-gray-900 dark:text-white"},"Certification Settings"),t("p",{class:"mt-1 text-sm text-gray-600 dark:text-gray-400"}," Wallet rewards, upload limits, and what each review step asks the student for. Saving stores every section at once. ")],-1)),a(ye)?(n(),d("div",vt,[o(a(pe),{size:24,class:"mx-auto animate-spin text-teal-500"})])):(n(),d(E,{key:1}
445,[t("div",kt,[(n(),d(E,null,N(be,b=>t("button",{key:b.key,type:"button",class:Oe(["border-b-2 px-4 py-2.5 text-sm transition-colors",m.value===b.key?"border-teal-500 font-medium text-gray-900 dark:text-white":"border-transparent text-gray-500 hover:text-gray-800 dark:hover:text-white"]),onClick:l=>m.value=b.key},s(b.label),11,xt)),64))]),g(o(a(Ge),{"validation-schema":a(fe),"initial-values":ve.value,onSubmit:Ce,onInvalidSubmit:_e,class:"max-w-xl rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800"},{default:M(({isSubmitting:b})=>[g(t("div",null,[o(a(z),{name:"enrollment_reward_usd",label:"Enrolment reward (USD)",min:0,hint:"Dollars added the moment a student opts in, so they have something to practise with. Set 0 for no joining reward."}),o(a(z),{name:"max_file_mb",label:"Default upload limit (MB)",min:1,hint:"Applies to the Step 6 and 7 screenshots, and to any file question or video-form field that doesn't set its own limit."}),o(a(k),{name:"accepted_file_types",label:"Default accepted file types",placeholder:"png, jpg, pdf, mp4",hint:"Comma-separated extensions, used by any file question that doesn't set its own. Step 6 and 7 screenshots are always images."}),o(a(_t),{name:"reviewer_emails",label:"Notify these people of new submissions",placeholder:"[email protected]","add-label":"Add another reviewer","first-label":"Add a reviewer","empty-text":"Nobody is notified. The queue still collects every submission — no one is told it's there.",hint:"Emailed the moment a student submits something needing a decision — the three review steps, and any test day asking for a link or a file."}),o(a(H),{name:"agency_links_enabled",label:"Offer agency sign-up links",description:"Gives every partner a link that signs a client up as an agency, alongside the business one. Off hides it for everyone — a single partner can be hidden from their student page instead."}),o(a(Ze),{name:"agency_invite_id",label:"Agency plan for partner sign-up links",options:xe.value,hint:"The plan a partner's “sell to an agency” link enrols a client on. Leave on the first active plan unless a specific one is wanted; a single partner can be pointed elsewhere from their student page."},null,8,["options"]),o(a(k),{name:"package_id",label:"Certification package ID",placeholder:"69e1c1a88324c3c41b9de740",hint:"The free package every partner account is subscribed to — their AI credit wallet lives on that subscription, so leaving this blank means credit awards fail. It also identifies certification sign-ups: with “Block sign-ups” on (Invites tab) this package refuses registration outright, so old shared /register?pkg= links stop working."})],512),[[C,m.value==="general"]]),g(t("div",null,[e[12]||(e[12]=t("p",{class:"mb-4 text-sm text-gray-500 dark:text-gray-400"}," Build the Video Submission step's form. Fields added here replace the built-in video URL field and render in this order; leave the list empty to keep the default form. The name for the certificate is always asked, whichever form the student sees. Answers land in your review queue. ",-1)),(n(!0),d(E,null,N(F.value,(l,L)=>(n(),d("div",{key:l.id||`new-${L}`,class:"mb-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700"},[t("div",wt,[t("div",$t,[g(t("input",{"onUpdate:modelValue":c=>l.label=c,type:"text",placeholder:"Field label, e.g. Client business name",class:"w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-teal-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900 dark:text-white"},null,8,Tt),[[P,l.label]]),t("div",St,[g(t("select",{"onUpdate:modelValue":c=>l.type=c,class:"rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-teal-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900 dark:text-white"},[(n(),d(E,null,N(we,c=>t("option",{key:c.value,value:c.value},s(c.label),9,Et)),64))],8,At),[[Le,l.type]]),g(t("input",{"onUpdate:modelValue":c=>l.hint=c,type:"text",placeholder:"Hint shown to the student (optional)",class:"min-w-48 flex-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-teal-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900 dark:text-white"},null,8,Ct),[[P,l.hint]]),t("label",Pt,[g(t("input",{type:"checkbox","onUpdate:modelValue":c=>l.required=c,class:"h-4 w-4 accent-teal-500"},null,8,Rt),[[Ke,l.required]]),e[7]||(e[7]=r(" Required ",-1))])]),l.type==="file"?(n(),d("div",It,[t("label",Ot,[e[8]||(e[8]=r(" Max size ",-1)),g(t("input",{"onUpdate:modelValue":c=>l.max_file_mb=c,type:"number",min:"1",placeholder:String($e.value),class:"w-20 rounded-lg border border-gray-300 px-2 py-1.5 text-sm focus:border-teal-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900 dark:text-white"},null,8,Lt),[[P,l.max_file_mb,void 0,{number:!0}]]),e[9]||(e[9]=r(" MB ",-1))]),t("label",Kt,[e[10]||(e[10]=r(" Accepts ",-1)),g(t("input",{"onUpdate:modelValue":c=>
445l.accepted_types=c,type:"text",placeholder:Te.value,title:"Comma-separated extensions, e.g. pdf, png",class:"w-44 rounded-lg border border-gray-300 px-2 py-1.5 text-sm focus:border-teal-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900 dark:text-white"},null,8,Ut),[[P,l.accepted_types]])])])):_("",!0)]),t("button",{type:"button",onClick:c=>F.value.splice(L,1),class:"mt-1 rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-500/20"},[o(a(ae),{size:16})],8,qt)])]))),128)),t("button",{type:"button",onClick:Ae,class:"mb-4 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"},[o(a(re),{size:15,class:"mr-1 inline"}),e[11]||(e[11]=r(" Add field ",-1))])],512),[[C,m.value==="video"]]),g(t("div",null,[o(a(z),{name:"step6_reward_usd",label:"Reward (USD)",min:0,hint:"Dollars added to the company's wallet when the posts are approved. 25 means $25."}),o(a(te),{name:"social_heading",label:"Intro shown above the cards",rows:3,hint:"What the student reads before the cards. Leave blank for none."}),e[15]||(e[15]=t("p",{class:"mt-6 mb-1 text-sm font-medium text-gray-700 dark:text-gray-200"},"Post cards",-1)),e[16]||(e[16]=t("p",{class:"mb-3 text-xs text-gray-500 dark:text-gray-400"}," One card per post the student must share — every card is required. The guidance link is optional and shown to them as a clickable link, e.g. the group to post in. ",-1)),(n(!0),d(E,null,N(V.value,(l,L)=>(n(),d("div",{key:L,class:"mb-3 rounded-lg border border-gray-200 p-4 dark:border-gray-700"},[t("div",zt,[t("span",Nt,s(L+1)+".",1),g(t("input",{"onUpdate:modelValue":c=>l.heading=c,type:"text",placeholder:"Heading, e.g. Facebook post 1",class:"flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-teal-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900 dark:text-white"},null,8,jt),[[P,l.heading]]),t("button",{type:"button",onClick:c=>V.value.splice(L,1),class:"mt-1 rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-500/20"},[o(a(ae),{size:16})],8,Ft)]),t("div",Vt,[g(t("input",{"onUpdate:modelValue":c=>l.link_label=c,type:"text",placeholder:"Link text, e.g. Group name",class:"w-52 rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-teal-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900 dark:text-white"},null,8,Dt),[[P,l.link_label]]),g(t("input",{"onUpdate:modelValue":c=>l.link_url=c,type:"url",placeholder:"https://facebook.com/groups/...",class:"min-w-56 flex-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm focus:border-teal-500 focus:outline-none dark:border-gray-600 dark:bg-gray-900 dark:text-white"},null,8,Gt),[[P,l.link_url]])]),t("p",Ht,[o(a(Xe),{size:13,class:"mr-1 inline"}),e[13]||(e[13]=r(" The student fills in a post URL and a screenshot on every card. ",-1))])]))),128)),t("button",{type:"button",onClick:Se,class:"rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"},[o(a(re),{size:16,class:"mr-1 inline"}),e[14]||(e[14]=r(" Add card ",-1))])],512),[[C,m.value==="social"]]),g(t("div",null,[o(a(z),{name:"step7_reward_usd",label:"Reward (USD)",min:0,hint:"Dollars added when the marketplace proofs are approved. 25 means $25."}),o(a(k),{name:"badge_platforms",label:"Marketplace platforms",placeholder:"upwork, fiverr",hint:"Comma-separated. The student links each profile and attaches a screenshot showing the badge on it."})],512),[[C,m.value==="badge"]]),g(t("div",null,[t("p",Mt,[e[18]||(e[18]=r(" The programme is invite-only. Every code works once, for one person. Intakes and the codes themselves are managed on the ",-1)),o(O,{to:"/admin/certification/invites",class:"font-medium text-teal-600 hover:underline dark:text-teal-400"},{default:M(()=>[...e[17]||(e[17]=[r(" Cohorts & Invites ",-1)])]),_:1}),e[19]||(e[19]=r(" screen — these are the defaults it uses. ",-1))]),o(a(H),{name:"invites_enabled",label:"Invite-only enrolment",description:"On: an invite code is required to join, and certified partners can invite others. Off: codes stop w
445orking — including ones already sent — and you enrol companies by hand from their company page."}),o(a(H),{name:"block_package_signup",label:"Block sign-ups through the certification package",description:"Refuses registration outright for the package ID above, so every copy of the old shared link stops working. Turn off and that package signs up like any other — it still won't enrol anyone in the programme."}),o(a(te),{name:"signup_blocked_message",label:"Message shown when a sign-up is blocked",rows:3,hint:"Shown for both switches above. Tell them how to get an invite — a refusal with no way forward is a support call. Reasons that belong to one code (expired, already used) keep their own wording."}),o(a(k),{name:"invite_landing_url",label:"Invite landing page",placeholder:"https://knockknockapp.ai/join",hint:"Where invite links and emails point. This page checks the code server-side and forwards valid ones to registration, so a dead code is turned away before anyone fills in a form. Leave blank to link straight to sign-up — still safe, just no greeting."}),o(a(z),{name:"partner_invite_quota",label:"Invites per certified partner",min:0,hint:"How many people each certified partner can invite. Uncertified students get none whatever this says. Set 0 so only you can issue invites."}),o(a(z),{name:"invite_expiry_days",label:"Codes expire after (days)",min:0,hint:"Applies to any code issued without its own expiry date, which is every partner-issued invite. Set 0 for codes that never expire — an unused one then works forever."})],512),[[C,m.value==="invites"]]),g(t("div",null,[t("p",Bt,[e[21]||(e[21]=r(" The apply page on the marketing site. Everyone who applies lands in the ",-1)),o(O,{to:"/admin/certification/applicants",class:"font-medium text-teal-600 hover:underline dark:text-teal-400"},{default:M(()=>[...e[20]||(e[20]=[r(" Applicants ",-1)])]),_:1}),e[22]||(e[22]=r(" queue, and accepting one issues them an invite code. ",-1))]),o(a(H),{name:"applications_enabled",label:"Taking applications",description:"Off closes the form immediately, whatever the date below says. Someone holding a partner's invite code still gets through — their seat was promised in advance."}),o(a(k),{name:"applications_close_on",label:"Applications close",type:"date",hint:"Shown on the apply page as the deadline, and the form stops accepting after it. Leave blank for no deadline — the switch above is then the only control."}),o(a(te),{name:"applications_closed_message",label:"Message shown when applications are closed",rows:2,hint:"Leave blank for the default. Tell them when the next round opens, or the page just says no."}),o(a(k),{name:"applicant_ghl_tag",label:"Tag applicants in our GoHighLevel",placeholder:"certification-applicant",hint:"Every applicant is pushed as a contact into our own GHL and given this tag, which is what your workflows trigger on. Comma-separate for more than one. Blank still pushes the contact, just untagged."}),t("div",Wt,[e[23]||(e[23]=t("h3",{class:"text-sm font-medium text-gray-900 dark:text-white"},"Stage tags",-1)),e[24]||(e[24]=t("p",{class:"mt-1 mb-4 text-sm text-gray-500 dark:text-gray-400"}," Written alongside the tag above as someone moves through the programme, so a workflow can trigger on the stage rather than on “an applicant exists”. A contact carries exactly one at a time — moving on removes the stage before it, and nothing else on the contact is touched. Leave one blank to tag nothing at that stage. ",-1)),t("div",Yt,[(n(),d(E,null,N(se,l=>o(a(k),{key:l.name,name:`stage_ghl_tags.${l.name}`,label:l.label,placeholder:l.placeholder,hint:l.hint},null,8,["name","label","placeholder","hint"])),64))]),o(a(k),{name:"invite_code_ghl_field",label:"Invite code custom field",placeholder:"Certification Invite Code",hint:"A text custom field in our own GoHighLevel, by name or key (for example contact.invite_code). The person's invite code is written there at every push, so a workflow can put it in an email. Leave blank to skip."}),t("div",Jt,[o(a(k),{name:"blocked_ghl_tag",label:"Blocked tag",placeholder:"certification-blocked",hint:"Added when a student's account is blocked, removed when unblocked. Sits beside the stage tag."}),o(a(k),{name:"archived_ghl_tag",label:"Archived tag",placeholder:"certification-archived",hint:"Added when the company is archived, removed on unarchive."})]),o(a(H),{name:"cohort_ghl_tag_enabled",label:"Tag the cohort too",description:"Adds the intake's own name — “Cohort 3” when it has no name — so one round can be addressed on its own. Set at every push, from the code they redeemed."})])],512),[[C,m.value==="applications"]]),t("button",{type:"submit",disabled:b,class:"rounded-lg bg-teal-600 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-teal-700 disabled:opacity-50"},[b?(n(),h(a(pe),{key:0,size:16,class:"mr-2 inline animate-spin"}
445)):_("",!0),e[25]||(e[25]=r(" Save Settings ",-1))],8,Qt)]),_:1},8,["validation-schema","initial-values"]),[[C,m.value!=="api"]]),m.value==="general"?(n(),h(Y,{key:0,class:"mt-6 max-w-3xl",title:"Backfill partner subscriptions",description:"Partners who signed up with an invite code never got a subscription, and the AI credit wallet lives on it — so every credit award fails for them. This subscribes each one to the certification package above. Anyone who has ever had a subscription of their own is left alone.",icon:a(Me),fields:[{key:"limit",label:"Limit (0 = everyone)",type:"number",default:0,min:0}],"confirm-apply":"This creates a free subscription for every partner account listed in the dry run. Dry-run first.",run:a(at),poll:a(tt)},null,8,["icon","run","poll"])):_("",!0),m.value==="applications"?(n(),h(Y,{key:1,class:"mt-6 max-w-3xl",title:"Backfill GHL tags",description:"Contacts pushed before stage tags existed carry the applicant tag only. This works out where each of them is now — applied, accepted, joined, enrolled, certified — and writes that tag plus their cohort. Safe to re-run: every run sets the stage they are at today and drops the others.",icon:a(Be),fields:[{key:"limit",label:"Limit (0 = everyone)",type:"number",default:0,min:0}],"confirm-apply":"This writes tags on real contacts in our own GoHighLevel, which can fire your workflows. Dry-run first.",run:a(nt),poll:a(rt)},null,8,["icon","run","poll"])):_("",!0),m.value==="applications"?(n(),h(Y,{key:2,class:"mt-4 max-w-3xl",title:"Add missing students to GHL",description:"Every joined, enrolled or certified student is looked up in our own GoHighLevel by email. Anyone with no contact at all gets one, carrying the stage and cohort tags they would have today. Students who already have a contact are left untouched.",icon:a(We),fields:[{key:"limit",label:"Limit (0 = everyone)",type:"number",default:0,min:0}],"confirm-apply":"This creates real contacts in our own GoHighLevel, which can fire your workflows. Dry-run first.",run:a(it),poll:a(ot)},null,8,["icon","run","poll"])):_("",!0),m.value==="applications"?(n(),h(Y,{key:3,class:"mt-4 max-w-3xl",title:"Why is this person not tagged?",description:"Everything we hold for one address: their application, their account, the stage that follows from it, the tags a push would write, and every contact in our GoHighLevel with that address. Two contacts is the usual answer — only the first is ever written to.",icon:a(Ye),fields:[{key:"email",label:"Email address",type:"text",placeholder:"[email protected]"}],"read-only":"",run:a(st)},null,8,["icon","run"])):_("",!0),m.value==="api"?(n(),d("section",Xt,[e[60]||(e[60]=t("p",{class:"text-sm text-gray-600 dark:text-gray-400"}," Endpoints the marketing site calls. All of them take a shared key and are rate limited, and every one is read server-to-server — none of this is meant to be reachable from a visitor's browser. ",-1)),t("div",Zt,[t("button",{type:"button",onClick:e[0]||(e[0]=b=>I(Z.value)),class:"inline-flex shrink-0 items-center gap-2 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-teal-700"},[u.value===Z.value?(n(),h(a(U),{key:0,size:16})):(n(),h(a(q),{key:1,size:16})),r(" "+s(u.value===Z.value?"Copied":"Copy everything on this page"),1)]),e[26]||(e[26]=t("p",{class:"min-w-48 flex-1 text-xs text-gray-600 dark:text-gray-400"},[r(" The whole reference as one markdown document — rules, endpoints, response shapes and the PHP — ready to paste into an AI and have it build the page. "),t("strong",null,"The key is a placeholder"),r(", not the real value: send that to whoever builds the page separately, never into a chat tool. ")],-1))]),e[61]||(e[61]=t("p",{class:"mt-3 rounded-lg border border-teal-200 bg-teal-50 p-3 text-sm text-teal-800 dark:border-teal-800 dark:bg-teal-900/20 dark:text-teal-300"},[t("strong",null,"Call these from PHP, not from the browser."),r(" WordPress fetches on the server, caches the result and renders the cards into the page — so the API host never appears in the page source or the network tab, the cards are in the HTML for search engines, and there is no CORS to configure. They are "),t("strong",null,"key-protected"),r(", so calling them from browser JavaScript would mean shipping the key to every visitor: don't. ")],-1)),t("div",ea,[e[29]||(e[29]=t("h3",{class:"mb-2 text-sm font-semibold text-gray-900 dark:text-white"},"Authentication",-1)),e[30]||(e[30]=t("p",{class:"text-sm text-gray-600 dark:text-gray-400"}," Every request sends the shared key as a header: ",-1)),t("div",ta,[t("co
445de",aa,[e[27]||(e[27]=t("span",{class:"text-gray-500"},"X-Partner-Key:",-1)),v.value?(n(),d(E,{key:1},[r(s(j.value?v.value:me.value),1)],64)):(n(),d("span",ra,"not set"))]),v.value?(n(),d("button",{key:0,type:"button",onClick:e[1]||(e[1]=b=>j.value=!j.value),class:"inline-flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"},[j.value?(n(),h(a(Je),{key:0,size:14})):(n(),h(a(Qe),{key:1,size:14})),r(" "+s(j.value?"Hide":"Reveal"),1)])):_("",!0),v.value?(n(),d("button",{key:1,type:"button",onClick:e[2]||(e[2]=b=>I(v.value)),class:"inline-flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"},[u.value===v.value?(n(),h(a(U),{key:0,size:14,class:"text-emerald-500"})):(n(),h(a(q),{key:1,size:14})),r(" "+s(u.value===v.value?"Copied":"Copy"),1)])):_("",!0)]),v.value?_("",!0):(n(),d("p",na,[...e[28]||(e[28]=[t("code",null,"PARTNER_API_KEY",-1),r(" is not set on the backend, so every endpoint below is answering ",-1),t("code",null,"503",-1),r(" and the directory is dark. Set it in the deployment config and restart. ",-1)])])),e[31]||(e[31]=W('<ul class="mt-3 space-y-1 text-xs text-gray-500 dark:text-gray-400"><li> Masked on purpose. <strong>Copy works without revealing</strong> — use it, and the value never lands in a screenshot, a screen share, or a support thread. </li><li> The value lives in <code>PARTNER_API_KEY</code> on the backend and is <strong>changed there</strong>, not here. This panel only reads it. </li><li> It is its own key, <strong>not</strong> the internal service key. A WordPress install is a different trust domain, so a leak there costs you this feed and nothing else — rotate <code>PARTNER_API_KEY</code> and you are done. </li><li> Without it set, these endpoints answer <code>503</code> rather than serving openly. Set the key before deploying, or the directory goes dark. </li><li> Partner photos stay public: <code>photo_file_id</code> resolves to a plain image URL that visitors&#39; browsers have to be able to load once the cards are on the page. </li></ul>',1))]),t("div",oa,[e[32]||(e[32]=t("div",{class:"mb-2 flex flex-wrap items-center gap-2"},[t("span",{class:"rounded bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300"}," LIVE "),t("h3",{class:"text-sm font-semibold text-gray-900 dark:text-white"},"Open intake")],-1)),e[33]||(e[33]=t("p",{class:"mb-3 text-sm text-gray-600 dark:text-gray-400"},[r(" The intake currently taking sign-ups and how many seats are left — the “N seats left” line on the directory page. Returns "),t("code",null,'{ "cohort": null }'),r(" when every intake is closed or full, so the page can hide the counter rather than print a zero. ")],-1)),t("div",ia,[t("code",{class:"flex-1 rounded-lg bg-gray-100 px-3 py-2 font-mono text-xs break-all text-gray-800 dark:bg-gray-900 dark:text-gray-200"}," GET "+s(T)),t("button",{type:"button",onClick:e[3]||(e[3]=b=>I(T)),class:"inline-flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"},[u.value===T?(n(),h(a(U),{key:0,size:14,class:"text-emerald-500"})):(n(),h(a(q),{key:1,size:14})),r(" "+s(u.value===T?"Copied":"Copy"),1)])]),t("pre",{class:"overflow-x-auto rounded-lg bg-gray-100 p-3 font-mono text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-200"},s(ge)),e[34]||(e[34]=W('<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"><code>seats_left</code> counts codes that have been <em>issued</em>, not redeemed — a seat is spoken for the moment an invite goes out, and comes back if the code is cancelled. <code>applications_open</code> is whether the apply form should render at all; read it rather than hard-coding the deadline into the page. </p><p class="mt-2 text-xs text-gray-500 dark:text-gray-400"><code>cohorts</code> carries every open intake, so a page can show more than one panel — the round being applied to, and a named intake like <code>Founding</code> counting its own seats down beside it. Pick by <code>name</code>; each is an ordinary cohort with its own <code>seats_left</code> and <code>ends_on</code>. </p>',2))]),t("div",sa,[e[38]||(e[38]=t("div",{class:"mb-1 flex items-center gap-2"},[t("h3",{class:"text-sm font-semibold text-gray-900 dark:text-white"},"Apply for a seat")],-1)),t("p",la,[e[36]||(e[36]=r(" Takes one application. Without a code it joins the queue on the ",-1)),o(O,{to:"/admin/certification/applicants",class:"font-medium text-teal-600 hover:underline dark:text-teal-400"},{default:M(()=>[...e[35]||(e[35]=[r(" Applicants ",-1)])]),_:1}),e[37]||(e[37]=r(" screen; with a valid partner invite code it is accepted on the spot and the response carries the sign-up link to redirect to. ",-1))]),t("div",da,[t("code",{class:"flex-1 rounded-lg bg-gray-100 px-3 py-2 font-mono text-xs break-all text-gray-800 dark:bg-gray-900 dark:text-gray-200"}," POST "+s(A)),t("button",{type:"button",onClick:e[4]||(e[4]=b=>I(A)),class:"inline-flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"},[u.value===A?(n(),h(a(U),{key:0,size:14,class:"text-emerald-500"})):(n(),h(a(q),{key:1,size:14})),r(" "+s(u.value===A?"Copied":"Copy"),1)])]),t("pre",{class:"overflow-x-auto rounded-lg bg-gray-100 p-3 font-mono text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-200"},s(ue)),e[39]||(e[39]=W('<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> Only <code>name</code> and <code>email</code> are required. The choice fields take the keys, not your labels: <code>ghl_accounts</code> is <code>none|1-3|4-10|10-plus</code>, <code>relationship</code> is <code>freelancer|own_agency|in_house|other</code>, <code>commitment</code> is <code>live|partial|replays|not_this_round</code>. Anything else is a 400. </p>',1))]),t("div",pa,[e[47]||(e[47]=t("div",{class:"mb-2 flex flex-wrap items-center gap-2"},[t("span",{class:"rounded bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300"}," LIVE "),t("h3",{class:"text-sm font-semibold text-gray-900 dark:text-white"},"Partner listings")],-1)),e[48]||(e[48]=t("p",{class:"mb-3 text-sm text-gray-600 dark:text-gray-400"},[r(" Every published listing — this replaces the hardcoded array in the directory page. Only partners who are certified, have consented, and have their listing switched on appear. Optional "),t("code",null,"?country=PK"),r(" (ISO code) and "),t("
445code",null,"?search="),r(" narrow it, though the page can just as well filter the full list in the browser. ")],-1)),t("div",ca,[t("code",{class:"flex-1 rounded-lg bg-gray-100 px-3 py-2 font-mono text-xs break-all text-gray-800 dark:bg-gray-900 dark:text-gray-200"}," GET "+s(S)),t("button",{type:"button",onClick:e[5]||(e[5]=b=>I(S)),class:"inline-flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"},[u.value===S?(n(),h(a(U),{key:0,size:14,class:"text-emerald-500"})):(n(),h(a(q),{key:1,size:14})),r(" "+s(u.value===S?"Copied":"Copy"),1)])]),t("pre",{class:"overflow-x-auto rounded-lg bg-gray-100 p-3 font-mono text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-200"},s(he)),t("ul",{class:"mt-2 space-y-1 text-xs text-gray-500 dark:text-gray-400"},[e[43]||(e[43]=t("li",null,[t("code",null,"countries"),r(" carries the filter pills, counted across the whole directory rather than the filtered slice — so the row never empties as someone filters. ")],-1)),e[44]||(e[44]=t("li",null,[t("code",null,"initials"),r(", "),t("code",null,"avatar_color"),r(" and "),t("code",null,"country"),r(" are derived server-side. Render them; don't store or recompute them, or a rename leaves two versions of the same partner. ")],-1)),t("li",null,[e[40]||(e[40]=t("code",null,"photo_file_id",-1)),e[41]||(e[41]=r(" is null when a partner hasn't uploaded one — that's when you fall back to the initials chip. Build the image URL as ",-1)),t("code",null,s(J)),e[42]||(e[42]=r("; it serves unauthenticated, so it can be linked straight from the page. ",-1))]),e[45]||(e[45]=t("li",null,[t("code",null,"whatsapp"),r(" is digits only, ready for "),t("code",null,"https://wa.me/<number>"),r(". It is public by definition: anyone can read it out of the page source, and partners are told so before they give one. ")],-1)),e[46]||(e[46]=t("li",null,[t("code",null,"website"),r(" is a full URL or an empty string. Strip the scheme for display — a card has no room for "),t("code",null,"https://"),r(" — but link the value as given. ")],-1))]),e[49]||(e[49]=t("h4",{class:"mt-4 mb-2 text-sm font-semibold text-gray-900 dark:text-white"}," One partner, for the profile page ",-1)),t("code",{class:"block rounded-lg bg-gray-100 px-3 py-2 font-mono text-xs break-all text-gray-800 dark:bg-gray-900 dark:text-gray-200"}," GET "+s(S)+"/<slug> "),e[50]||(e[50]=t("p",{class:"mt-2 text-xs text-gray-500 dark:text-gray-400"},[r(" Returns "),t("code",null,'{ "partner": { … } }'),r(", same shape as a card. 404 for an unknown slug "),t("em",null,"and"),r(" for a listing that has been taken down — so the endpoint can't be used to confirm someone was removed. The slug is frozen at first publish, so these URLs are safe to index. ")],-1))]),t("div",{class:"mt-6 border-t border-gray-200 pt-5 dark:border-gray-700"},[e[51]||(e[51]=t("h3",{class:"mb-2 text-sm font-semibold text-gray-900 dark:text-white"}," Using it in WordPress (server-side) ",-1)),e[52]||(e[52]=t("p",{class:"mb-3 text-sm text-gray-600 dark:text-gray-400"},[r(" One helper does the fetching and caching. Drop it in the theme's "),t("code",null,"functions.php"),r(" or a small plugin, then render from it in the template. ")],-1)),t("pre",{class:"overflow-x-auto rounded-lg bg-gray-100 p-3 font-mono text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-200"},s(X)),e[53]||(e[53]=t("h4",{class:"mt-4 mb-2 text-sm font-semibold text-gray-900 dark:text-white"}," Rendering the cards ",-1)),t("pre",{class:"overflow-x-auto rounded-lg bg-gray-100 p-3 font-mono text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-200"},s(oe)),e[54]||(e[54]=W('<div class="mt-4 rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/50"><p class="text-xs font-semibold text-gray-800 dark:text-gray-200"> Rate limit, and why the cache still matters </p><p class="mt-1 text-xs text-gray-600 dark:text-gray-400"> These two endpoints allow <strong>60 requests per minute per IP</strong> — sized for server-side use, where every visitor to the site shares your server&#39;s one address. With the 5-minute transient above you will use about one request per five minutes, so there is enormous headroom; without it, a page doing 60 views a minute is already at the ceiling. Over the limit you get <code>429</code> with a <code>Retry-After</code> header, which is why the helper falls back to its last good copy instead of rendering an empty directory. </p></div><p class="mt-3 text-xs text-gray-500 dark:text-gray-400"><strong>Filter in the browser, not over the network.</strong> The country pills and search box should filter the cards already on the page. The <code>?country=</code> and <code>?search=</code>
445 parameters exist for other consumers — calling the API on every pill click would spend the rate limit and undo the point of rendering server-side. </p>',2))]),t("div",ha,[e[55]||(e[55]=t("div",{class:"mb-2 flex flex-wrap items-center gap-2"},[t("span",{class:"rounded bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300"}," LIVE "),t("h3",{class:"text-sm font-semibold text-gray-900 dark:text-white"},"Invite landing page")],-1)),e[56]||(e[56]=t("p",{class:"mb-3 text-sm text-gray-600 dark:text-gray-400"}," Checks an invite code and, if it is good, sends the invitee on to sign-up already greeted by name and cohort. This is why the check endpoint carries the key rather than being open: the code is verified here, on your server, and never by a browser. ",-1)),t("div",ga,[t("code",{class:"flex-1 rounded-lg bg-gray-100 px-3 py-2 font-mono text-xs break-all text-gray-800 dark:bg-gray-900 dark:text-gray-200"}," GET "+s(w)),t("button",{type:"button",onClick:e[6]||(e[6]=b=>I(w)),class:"inline-flex items-center gap-1.5 rounded-lg border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"},[u.value===w?(n(),h(a(U),{key:0,size:14,class:"text-emerald-500"})):(n(),h(a(q),{key:1,size:14})),r(" "+s(u.value===w?"Copied":"Copy"),1)])]),t("pre",{class:"overflow-x-auto rounded-lg bg-gray-100 p-3 font-mono text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-200"},s(ce)),e[57]||(e[57]=t("p",{class:"mt-2 mb-3 text-xs text-gray-500 dark:text-gray-400"},[r(" Always "),t("code",null,"200"),r(" — "),t("code",null,"valid"),r(" is the answer, and "),t("code",null,"message"),r(" is written for the invitee, so show it to them as-is. It already distinguishes an expired code from a used one from a closed intake. ")],-1)),e[58]||(e[58]=t("h4",{class:"mb-2 text-sm font-semibold text-gray-900 dark:text-white"},"The landing page",-1)),t("pre",ua,s(R.value),1),e[59]||(e[59]=t("p",{class:"mt-2 text-xs text-gray-500 dark:text-gray-400"},[r(" Point "),t("strong",null,"Invite landing page"),r(" on the Invites tab at wherever you publish this, and every invite link and email will use it. Leave that blank and invites go straight to sign-up — which still works, because the code is validated again on submit whatever the browser was told; the invitee just arrives without a greeting. ")],-1))])])):_("",!0)],64))])}}};export{Ua as default};

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.