📣 LiveStalksMarketMaker — Vendor Outreach

Loading…
`; } function h1(t){return `

${t}

`;} function p(t){return `

${t}

`;} function btn(t){return `

${t}

`;} function box(inner){return `
${inner}
`;} function ul(items){return ` `;} // Each template: {id, name, category, subject, html}. Copy is professional and // ready-to-send; merge fields are substituted per-recipient at send time. const TEMPLATES = [ { id:'kickoff', name:'Season Kickoff / Welcome', category:'Announcement', subject:'🌾 Welcome to the {{season}} season at {{market_name}}!', html: shell( h1('Welcome back, {{vendor_name}}! 🌾') + p('We are thrilled to kick off the {{season}} season at {{market_name}} in {{market_town}}. After months of planning, we cannot wait to fill the aisles with the very best from local growers, makers, and producers like you.') + p('Our first market day is {{next_date}}. Here is what you can look forward to this season:') + ul(['A refreshed layout with improved foot traffic and signage','Expanded marketing to bring more shoppers to your booth','New community events and live music on select dates']) + p('Your spot is reserved — we just ask that you confirm your dates and any updates to your offerings as soon as you can.') + btn('Confirm my dates') + p('Thank you for being part of what makes {{market_name}} special. Here is to a wonderful season ahead!') + p('Warmly,
{{manager_name}}
Market Manager, {{market_name}}') ) }, { id:'available', name:'Booths Still Available', category:'Recruitment', subject:'A few booths are still open at {{market_name}} — reserve yours', html: shell( h1('Spots are filling up — reserve yours 🌾') + p('Hi {{vendor_name}},') + p('We still have a handful of vendor booths open at {{market_name}}, and we would love to see you there. Our next market day is {{next_date}} in {{market_town}}, and these spots tend to go quickly.') + box('Why vendors love {{market_name}}:
• Steady, loyal weekly foot traffic
• Simple online booking and payment
• A supportive community of local producers') + p('If you would like to claim a booth — or add additional dates to your season — now is the time. Spots are offered first-come, first-served.') + btn('Reserve my booth') + p('Questions about availability or pricing? Just reply and I will help you find the right fit.') + p('Best,
{{manager_name}}
{{market_name}}') ) }, { id:'renewal', name:'Application / Renewal Reminder', category:'Reminder', subject:'Reminder: {{market_name}} vendor application due soon', html: shell( h1('A friendly reminder to renew 🌾') + p('Hi {{vendor_name}},') + p('Just a quick note that vendor applications for the upcoming season at {{market_name}} are due soon. To keep your spot and lock in your preferred dates, please complete your renewal at your earliest convenience.') + box('To renew, you will need:
• Your updated product list & categories
• Current certificate of insurance (if applicable)
• Your requested market dates') + p('Applications are reviewed in the order they are received, so renewing early gives you the best choice of booth location and dates.') + btn('Complete my renewal') + p('We would hate to see your booth go to the waitlist — please reach out if you have any questions or need an extension.') + p('Thank you,
{{manager_name}}
{{market_name}}') ) }, { id:'payment', name:'Payment Reminder', category:'Reminder', subject:'Action needed: balance due to confirm your {{market_name}} booth', html: shell( h1('Your booth is being held — balance due 🌾') + p('Hi {{vendor_name}},') + p('Thank you for booking booth {{booth_label}} at {{market_name}}! Your spot is currently being held, but it is not fully confirmed until your balance is paid.') + box('To guarantee your booth for {{next_date}}, please complete payment as soon as possible. Unpaid holds may be released to vendors on the waitlist.') + p('Paying online takes just a moment, and you will receive an instant confirmation once it goes through.') + btn('Pay now to confirm') + p('If you have already paid, thank you — please disregard this note. If you think this is a mistake or need to adjust your dates, just reply and I will sort it out.') + p('Appreciated,
{{manager_name}}
{{market_name}}') ) }, { id:'logistics', name:'Market-Day Logistics & Load-In', category:'Logistics', subject:'Load-in details for {{market_name}} on {{next_date}}', html: shell( h1('Everything you need for market day 🌾') + p('Hi {{vendor_name}},') + p('We are looking forward to seeing you at {{market_name}} in {{market_town}} on {{next_date}}! Here are the key details to make load-in smooth.') + box('Your booth: {{booth_label}}
Load-in: begins 90 minutes before opening — please arrive early
Vehicles: must be off the market floor 30 minutes before opening
Teardown: only after the market officially closes') + p('A few reminders to keep the day running well:') + ul(['Bring your own canopy weights — wind can pick up quickly','Pack out all trash and recycling from your space','Check in with the manager tent when you arrive']) + p('If you need to cancel or are running late, please let us know as early as you can so we can plan the layout.') + p('See you Saturday!
{{manager_name}}
{{market_name}}') ) }, { id:'weather', name:'Weather / Cancellation Notice', category:'Alert', subject:'⚠️ Weather update for {{market_name}} — {{next_date}}', html: shell( h1('Important weather update ⚠️') + p('Hi {{vendor_name}},') + p('We are closely watching the forecast for {{market_name}} on {{next_date}}. The safety of our vendors and shoppers always comes first.') + box('Status: Please plan to proceed as scheduled for now. If conditions change, we will send a final go / no-go decision by early that morning.

Watch your email and texts for the update.') + p('If the market is cancelled, your booth fee will be credited toward a future date — no action needed on your part. If we proceed, please secure your canopy with weights and bring rain protection for your products.') + p('Thank you for your flexibility. We will be in touch the moment we have a final call.') + p('Stay safe,
{{manager_name}}
{{market_name}}') ) }, { id:'recap', name:'End-of-Season Thank-You & Recap', category:'Recap', subject:'Thank you for an incredible season at {{market_name}} 🌾', html: shell( h1('What a season it has been! 🌾') + p('Dear {{vendor_name}},') + p('As the {{season}} season at {{market_name}} comes to a close, we want to say a heartfelt thank you. None of it would have been possible without vendors like you showing up, week after week, with the best of what you grow and make.') + box('A few highlights from this season:
• Record shopper attendance on peak market days
• Dozens of new vendors welcomed to the community
• More dollars than ever kept right here in {{market_town}}') + p('We are already dreaming up next season, and we hope you will be part of it. Keep an eye on your inbox over the off-season for renewal details and early-bird booth offers.') + btn('Join us next season') + p('Until then — rest up, and thank you again for everything.') + p('With gratitude,
{{manager_name}}
{{market_name}}') ) }, { id:'newsletter', name:'Monthly Vendor Newsletter', category:'Newsletter', subject:'{{market_name}} vendor news — your monthly update', html: shell( h1('Your monthly vendor update 🌾') + p('Hi {{vendor_name}},') + p('Here is what is happening at {{market_name}} this month, plus a few things to have on your radar.') + box('📅 Next market day: {{next_date}}
📍 Where: {{market_town}}') + p('This month at the market') + ul(['Featured promotions: we are spotlighting seasonal produce across our social channels — let us know what you will have so we can feature you','Vendor spotlight: reply if you would like to be featured in next month’s newsletter','Community events: live music and kids’ activities on select dates to boost foot traffic']) + p('A quick ask: please keep your product list and dates up to date in your dashboard so shoppers know what to expect.') + btn('Update my listing') + p('As always, thank you for being part of {{market_name}}. Reply any time with questions or ideas.') + p('Cheers,
{{manager_name}}
{{market_name}}') ) } ]; // merge-field documentation shown in the UI const MERGE_FIELDS = [ ['{{vendor_name}}','The vendor / farm name'], ['{{market_name}}','The selected market’s name'], ['{{market_town}}','The market’s town'], ['{{next_date}}','The market’s next event date'], ['{{manager_name}}','Your display name'], ['{{booth_label}}','The vendor’s booth label (if known)'], ['{{season}}','Season label (e.g. the current year)'] ]; // ============================================================ // MERGE-FIELD SUBSTITUTION (safe: text replacement only) // ============================================================ // Values are inserted as plain text into the manager's own HTML. Field values // are HTML-escaped so a vendor name with <, > or & can't break the markup. function mergeContext(rec, mkt){ const market = mkt && mkt.id !== 'all' ? mkt : (MARKETS[0] || {}); const nextDate = marketNextDate(market); const town = market.town ? esc(market.town) : ''; return { '{{vendor_name}}' : esc(rec && rec.name ? rec.name : 'there'), '{{market_name}}' : esc(market.name || 'our market'), '{{market_town}}' : town, '{{next_date}}' : nextDate || 'an upcoming date', '{{manager_name}}' : esc((PROFILE && PROFILE.display_name) || (USER && USER.email) || 'the market team'), '{{booth_label}}' : esc(rec && rec.booth_label ? rec.booth_label : 'your booth'), '{{season}}' : esc(String(new Date().getFullYear())), // derived helper used inside the shell footer '{{market_town_clause}}': town ? ' in ' + town : '' }; } function applyMerge(str, ctx){ let out = String(str==null?'':str); for(const k of Object.keys(ctx)){ out = out.split(k).join(ctx[k]); } return out; } function marketNextDate(market){ const ds = (market && market.market_dates) || []; const today = new Date(); today.setHours(0,0,0,0); const future = ds .map(d => d.event_date) .filter(Boolean) .filter(d => { const t = parseISO(d); return t && t >= today; }) .sort(); const pick = future[0] || ds.map(d=>d.event_date).filter(Boolean).sort().slice(-1)[0]; return pick ? fmtDate(pick) : ''; } function parseISO(iso){ const m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})/); if(!m) return null; const d = new Date(+m[1], +m[2]-1, +m[3]); d.setHours(0,0,0,0); return d; } function fmtDate(iso){ const d = parseISO(iso); if(!d) return esc(iso); return d.toLocaleDateString(undefined,{month:'short',day:'numeric',year:'numeric'}); } // ============================================================ // AUTH // ============================================================ function renderAuth(){ $('#refreshBtn').style.display='none'; $('#signoutBtn').style.display='none'; $('#ctxWrap').innerHTML=''; app.innerHTML = `

Manager sign in

Send professional emails to the vendors at the markets you organize.

`; $('#authForm').addEventListener('submit', async (e)=>{ e.preventDefault(); const btn=$('#signinBtn'); btn.disabled=true; btn.textContent='Signing in…'; $('#authErr').classList.remove('show'); const { error } = await supabase.auth.signInWithPassword({ email: $('#email').value.trim(), password: $('#password').value }); if(error){ showAuthErr(error.message || 'Sign-in failed.'); btn.disabled=false; btn.textContent='Sign in'; return; } boot(); }); } function showAuthErr(msg){ const e=$('#authErr'); if(e){ e.textContent=msg; e.classList.add('show'); } } async function signOut(){ await supabase.auth.signOut(); USER=null; PROFILE=null; MARKETS=[]; RECIPIENTS=[]; selected=new Set(); selectedMarket='all'; renderAuth(); } // ============================================================ // DATA LOAD // ============================================================ async function loadData(){ // markets owned by this manager (+ their dates for {{next_date}}) const mRes = await supabase .from('markets') .select('id,name,town,type,market_dates(id,event_date,label)') .eq('owner_id', USER.id); if(mRes.error) throw new Error('Markets query failed: ' + mRes.error.message); MARKETS = (mRes.data || []).slice().sort((a,b)=>String(a.name||'').localeCompare(String(b.name||''))); // profile (manager display name + email) const pRes = await supabase.from('profiles').select('display_name,email').eq('id', USER.id).maybeSingle(); PROFILE = (pRes.data) || { display_name: USER.email, email: USER.email }; // bookings in the manager's markets, joined to the farm (the vendor to email) RECIPIENTS = []; if(MARKETS.length){ const marketIds = MARKETS.map(m=>m.id); const bRes = await supabase .from('bookings') .select('market_id, booth:booths(label), farm:farms(id,name,email,category)') .in('market_id', marketIds); if(bRes.error) throw new Error('Bookings query failed: ' + bRes.error.message); RECIPIENTS = dedupeRecipients(bRes.data || []); } // default selection: all vendors with an email selected = new Set(RECIPIENTS.filter(r=>r.has_email).map(r=>r.email)); } // Dedupe vendors. Vendors with an email are keyed by email; vendors without one // are kept (keyed by farm id) so the manager can see who's missing an address. function dedupeRecipients(rows){ const byKey = new Map(); for(const b of rows){ const f = b.farm; if(!f) continue; const email = (f.email||'').trim().toLowerCase(); const key = email || ('farm:'+f.id); let rec = byKey.get(key); if(!rec){ rec = { farm_id: f.id, name: f.name || '(unnamed vendor)', email: email, category: f.category || '', booth_label: (b.booth && b.booth.label) || '', market_ids: new Set(), has_email: !!email }; byKey.set(key, rec); } if(b.market_id) rec.market_ids.add(b.market_id); if(!rec.booth_label && b.booth && b.booth.label) rec.booth_label = b.booth.label; } return Array.from(byKey.values()).sort((a,b)=>a.name.localeCompare(b.name)); } function visibleRecipients(){ if(selectedMarket==='all') return RECIPIENTS; return RECIPIENTS.filter(r=>r.market_ids.has(selectedMarket)); } function selectedRecipients(){ // only emailable + currently visible + checked return visibleRecipients().filter(r=>r.has_email && selected.has(r.email)); } // ============================================================ // MAIN RENDER // ============================================================ function render(){ $('#refreshBtn').style.display=''; $('#signoutBtn').style.display=''; $('#ctxWrap').innerHTML = `👤 ${esc(PROFILE.display_name||USER.email)}`; if(!MARKETS.length){ app.innerHTML = `

No markets yet

You don't own any markets, so there are no vendors to email. Create a market in the platform first, then come back here.

`; return; } const draft = loadDraft(); const tplOpts = `` + MARKETS.map(m=>``).join(''); app.innerHTML = `

Vendor Outreach

Compose and send professional emails to your market vendors.

Recipients

0 selected

1 · Choose a template

✏️ Blank
Start from scratch
${TEMPLATES.map(t=>`
${esc(t.name)}
${esc(t.category)}
`).join('')}

2 · Compose

Merge fields reference

These are replaced per-recipient when you send. Click a chip to insert it at the cursor.

${MERGE_FIELDS.map(f=>`
${esc(f[0])} — ${esc(f[1])}
`).join('')}

3 · Send

Delivery happens via the notify function (outbox).
Works without the notify function deployed:
`; // wire it up $('#mktFilter').value = selectedMarket; $('#mktFilter').onchange = e => { selectedMarket = e.target.value; renderRecipients(); updatePreview(); updateCounts(); }; $('#selAll').onclick = ()=>{ visibleRecipients().forEach(r=>{ if(r.has_email) selected.add(r.email); }); renderRecipients(); updateCounts(); updatePreview(); }; $('#selNone').onclick = ()=>{ visibleRecipients().forEach(r=>selected.delete(r.email)); renderRecipients(); updateCounts(); updatePreview(); }; $$('#tplGrid .tpl').forEach(el=>{ el.onclick = ()=>{ applyTemplate(el.dataset.tpl); }; }); $('#resetTpl').onclick = ()=> applyTemplate(currentTplId, true); $$('.fmtbar [data-wrap]').forEach(b=> b.onclick = ()=> wrapSelection(b.dataset.wrap)); $('#subject').oninput = ()=>{ saveDraftSilent(); }; $('#body').oninput = ()=>{ updatePreview(); saveDraftSilent(); }; // merge chips $('#mergeChips').innerHTML = MERGE_FIELDS.map(f=>`${esc(f[0])}`).join(''); $$('#mergeChips .chip').forEach(c=> c.onclick = ()=> insertAtCursor($('#body'), c.dataset.ins)); $('#sendAll').onclick = sendAll; $('#sendTest').onclick = sendTest; $('#copyHtml').onclick = copyHtml; $('#dlHtml').onclick = downloadHtml; $('#mailto').onclick = openMailto; $('#saveDraft').onclick = ()=>{ saveDraftSilent(); toast('Draft saved.'); }; renderRecipients(); updateCounts(); updatePreview(); } function renderRecipients(){ const list = $('#recList'); if(!list) return; const vis = visibleRecipients(); const noEmail = vis.filter(r=>!r.has_email); const warn = $('#recWarn'); warn.innerHTML = noEmail.length ? `` : ''; if(!vis.length){ list.innerHTML = `
No vendors found for this market.
`; return; } list.innerHTML = vis.map(r=>{ const id = 'r_'+esc(r.farm_id); if(!r.has_email){ return `
${esc(r.name)}
no email · ${esc(r.category||'vendor')}
`; } const checked = selected.has(r.email) ? 'checked' : ''; return `
`; }).join(''); $$('#recList input[type=checkbox]:not([disabled])').forEach(cb=>{ cb.onchange = ()=>{ if(cb.checked) selected.add(cb.dataset.email); else selected.delete(cb.dataset.email); updateCounts(); updatePreview(); }; }); } function updateCounts(){ const n = selectedRecipients().length; const c1=$('#recCount'), c2=$('#sendCount'); if(c1) c1.textContent=n; if(c2) c2.textContent=n; const sa=$('#sendAll'); if(sa) sa.disabled = n===0; } // ============================================================ // TEMPLATES / PREVIEW // ============================================================ function applyTemplate(id, force){ currentTplId = id; $$('#tplGrid .tpl').forEach(el=> el.classList.toggle('active', el.dataset.tpl===id)); const body = $('#body'), subj = $('#subject'); if(id==='blank'){ if(force || (!body.value.trim())){ body.value=''; subj.value=''; } } else { const t = TEMPLATES.find(x=>x.id===id); if(t){ body.value = t.html; subj.value = t.subject; } } updatePreview(); saveDraftSilent(); } function currentMarket(){ return selectedMarket==='all' ? (MARKETS[0]||{id:'all'}) : (MARKETS.find(m=>m.id===selectedMarket)||{id:'all'}); } function updatePreview(){ const iframe = $('#preview'); if(!iframe) return; const sample = selectedRecipients()[0] || visibleRecipients().find(r=>r.has_email) || { name:'Green Acres Farm', booth_label:'A1' }; const ctx = mergeContext(sample, currentMarket()); const html = applyMerge($('#body').value || '

Your email preview will appear here. Pick a template or start typing.

', ctx); const doc = iframe.contentDocument || iframe.contentWindow.document; doc.open(); doc.write(html); doc.close(); } // ============================================================ // EDITOR HELPERS // ============================================================ function insertAtCursor(ta, text){ const s=ta.selectionStart, e=ta.selectionEnd, v=ta.value; ta.value = v.slice(0,s)+text+v.slice(e); ta.selectionStart = ta.selectionEnd = s+text.length; ta.focus(); updatePreview(); saveDraftSilent(); } function wrapSelection(tpl){ const ta=$('#body'); const s=ta.selectionStart,e=ta.selectionEnd,v=ta.value; const [pre,post] = tpl.split('|'); const inner = v.slice(s,e); ta.value = v.slice(0,s)+pre+inner+post+v.slice(e); ta.selectionStart = s+pre.length; ta.selectionEnd = s+pre.length+inner.length; ta.focus(); updatePreview(); saveDraftSilent(); } // ============================================================ // DRAFTS (localStorage) // ============================================================ function loadDraft(){ try{ return JSON.parse(localStorage.getItem(DRAFT_KEY)||'{}'); }catch(e){ return {}; } } function saveDraftSilent(){ try{ const subjEl=$('#subject'), bodyEl=$('#body'); if(!subjEl||!bodyEl) return; localStorage.setItem(DRAFT_KEY, JSON.stringify({ subject:subjEl.value, body:bodyEl.value, template:currentTplId })); }catch(e){} } // ============================================================ // SEND // ============================================================ function validateCompose(){ const subject = $('#subject').value.trim(); const body = $('#body').value.trim(); if(!subject){ toast('Please enter a subject.', true); return null; } if(!body){ toast('The email body is empty.', true); return null; } return { subject, body }; } async function sendAll(){ const c = validateCompose(); if(!c) return; const recips = selectedRecipients(); if(!recips.length){ toast('No recipients selected.', true); return; } const market = currentMarket(); if(!market || market.id==='all'){ toast('Select a specific market to send (it sets {{market_name}} and ownership).', true); return; } const btn=$('#sendAll'); btn.disabled=true; const orig=btn.innerHTML; btn.textContent='Sending…'; let queued=0, failed=0; // Merge fields differ per vendor, so render + queue each recipient individually. for(const r of recips){ const ctx = mergeContext(r, market); const subject = applyMerge(c.subject, ctx); const html = applyMerge(c.body, ctx); const { data, error } = await supabase.rpc('send_broadcast', { p_market: market.id, p_subject: subject, p_html: html, p_recipients: [r.email] }); if(error){ failed++; } else { queued += (typeof data==='number' ? data : 1); } } btn.disabled=false; btn.innerHTML=orig; updateCounts(); if(failed && !queued){ toast(`Send failed for all ${failed} recipients. Is the migration applied? (${failed} errors)`, true); } else if(failed){ toast(`Queued ${queued} email${queued>1?'s':''}; ${failed} failed. Delivery happens via the notify function.`, false); } else { toast(`✅ Queued ${queued} personalized email${queued>1?'s':''}! Delivery happens via the notify function (outbox).`); } } async function sendTest(){ const c = validateCompose(); if(!c) return; const market = currentMarket(); if(!market || market.id==='all'){ toast('Select a specific market first.', true); return; } const me = (PROFILE && PROFILE.email) || USER.email; const sampleRec = selectedRecipients()[0] || { name:(PROFILE&&PROFILE.display_name)||'You', booth_label:'A1' }; const ctx = mergeContext(sampleRec, market); const subject = '[TEST] ' + applyMerge(c.subject, ctx); const html = applyMerge(c.body, ctx); const btn=$('#sendTest'); btn.disabled=true; const o=btn.textContent; btn.textContent='Sending…'; const { error } = await supabase.rpc('send_broadcast', { p_market: market.id, p_subject: subject, p_html: html, p_recipients: [me] }); btn.disabled=false; btn.textContent=o; if(error) toast('Test send failed: '+error.message, true); else toast(`✅ Test queued to ${me}. It'll arrive once the notify function runs.`); } // ---------- fallbacks (no backend needed) ---------- function renderedForFallback(){ const c = validateCompose(); if(!c) return null; const market = currentMarket(); const sample = selectedRecipients()[0] || visibleRecipients().find(r=>r.has_email) || { name:'Vendor', booth_label:'A1' }; const ctx = mergeContext(sample, market); return { subject: applyMerge(c.subject, ctx), html: applyMerge(c.body, ctx), market }; } async function copyHtml(){ const r = renderedForFallback(); if(!r) return; try{ await navigator.clipboard.writeText(r.html); toast('HTML copied to clipboard.'); } catch(e){ toast('Copy failed — your browser blocked clipboard access.', true); } } function downloadHtml(){ const r = renderedForFallback(); if(!r) return; const blob = new Blob([r.html], {type:'text/html'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href=url; a.download = (r.subject||'email').replace(/[^a-z0-9]+/gi,'_').slice(0,50)+'.html'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); toast('Downloaded .html'); } function openMailto(){ const recips = selectedRecipients(); if(recips.length!==1){ toast('Select exactly one recipient to use mailto.', true); return; } const c = validateCompose(); if(!c) return; const ctx = mergeContext(recips[0], currentMarket()); const subject = applyMerge(c.subject, ctx); // mailto body is plain text; strip tags for a usable fallback. const plain = applyMerge(c.body, ctx).replace(//gi,'').replace(/<[^>]+>/g,'').replace(/\n{3,}/g,'\n\n').trim(); const href = `mailto:${encodeURIComponent(recips[0].email)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(plain)}`; window.location.href = href; } // ============================================================ // TOAST // ============================================================ let toastTimer=null; function toast(msg, isErr){ const t=$('#toast'); t.textContent=msg; t.classList.toggle('err', !!isErr); t.classList.add('show'); clearTimeout(toastTimer); toastTimer=setTimeout(()=>t.classList.remove('show'), isErr?6000:4500); } // ============================================================ // BOOT // ============================================================ async function boot(){ app.innerHTML = `
Loading your markets & vendors…
`; try{ const { data:{ session } } = await supabase.auth.getSession(); if(!session){ renderAuth(); return; } USER = session.user; await loadData(); render(); }catch(e){ app.innerHTML = `
`; } } $('#refreshBtn').onclick = boot; $('#signoutBtn').onclick = signOut; supabase.auth.onAuthStateChange((event)=>{ if(event==='SIGNED_OUT'){ renderAuth(); } }); boot();