From 488b5ec12d05f1ef1ed5d027ddfb12bc9b062097 Mon Sep 17 00:00:00 2001 From: Optio Agent Date: Mon, 31 Aug 2026 19:04:28 +0000 Subject: [PATCH] Add bug report / feature request contact form at /contact Closes #18 Adds a web form at /contact where users can submit bug reports and feature requests with screenshots directly to k1af@ft8af.app, in addition to GitHub issues. - api/contact.js: Vercel serverless function that validates the submission and sends the email via nodemailer (SMTP) - src/pages/contact.mjs: static page template with the form HTML - public/assets/contact.css: form styles following site design tokens - public/assets/ft8af.js: client-side form handler (type switching, drag-and-drop image upload with 5-file / 2 MB limits, base64 conversion, JSON POST, success/error states) - src/i18n/en.json: contact namespace translations - build.mjs: "Feedback" nav link and updated footer "Report a bug" link - src/pages/_registry.mjs: register the new page Email delivery requires SMTP_HOST, SMTP_USER, and SMTP_PASS env vars set in Vercel. CONTACT_TO defaults to k1af@ft8af.app. Co-Authored-By: Claude Sonnet 4.6 --- api/contact.js | 136 ++++ build.mjs | 5 +- package-lock.json | 15 +- package.json | 3 +- public/assets/contact.css | 187 +++++ public/assets/ft8af.js | 208 +++++- public/download.html | 37 +- public/faq.html | 22 +- public/features.html | 14 +- public/index.html | 30 +- public/sitemap.xml | 1280 ++++++++++++++++++++++++++++++++++ public/wiki.html | 14 +- public/wiki/bug-reports.html | 48 +- public/wiki/using.html | 40 +- src/i18n/en.json | 61 +- src/pages/_registry.mjs | 3 +- src/pages/contact.mjs | 126 ++++ 17 files changed, 2203 insertions(+), 26 deletions(-) create mode 100644 api/contact.js create mode 100644 public/assets/contact.css create mode 100644 src/pages/contact.mjs diff --git a/api/contact.js b/api/contact.js new file mode 100644 index 0000000..5f27713 --- /dev/null +++ b/api/contact.js @@ -0,0 +1,136 @@ +import nodemailer from 'nodemailer'; + +const TO = process.env.CONTACT_TO || 'k1af@ft8af.app'; +const FROM = process.env.SMTP_FROM || 'FT8AF '; +const MAX_IMAGES = 5; +const MAX_IMG_BYTES = 2_097_152; // 2 MB per image + +function esc(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function row(label, value) { + if (!value || !String(value).trim()) return ''; + const formatted = String(value).replace(/\n/g, '
'); + return ` + ${esc(label)} + ${esc(String(value)).replace(/\n/g, '
')} + `; +} + +export default async function handler(req, res) { + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ error: 'Method not allowed' }); + } + + let body; + try { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + return res.status(400).json({ error: 'Invalid request body' }); + } + + const { type, title, steps, expected, actual, usecase, description, appVersion, androidVersion, radioModel, images } = body; + + if (type !== 'bug' && type !== 'feature') { + return res.status(400).json({ error: 'Invalid type' }); + } + if (!title || typeof title !== 'string' || !title.trim()) { + return res.status(400).json({ error: 'Summary is required' }); + } + if (type === 'bug' && (!steps || !String(steps).trim())) { + return res.status(400).json({ error: 'Steps to reproduce are required for bug reports' }); + } + if (type === 'feature' && (!usecase || !String(usecase).trim())) { + return res.status(400).json({ error: 'Use case is required for feature requests' }); + } + + const typeLabel = type === 'bug' ? 'Bug Report' : 'Feature Request'; + + const bugSection = type === 'bug' ? ` + ${row('Steps to Reproduce', steps)} + ${row('Expected Behavior', expected)} + ${row('Actual Behavior', actual)} + ${row('App Version', appVersion)} + ${row('Android Version', androidVersion)} + ${row('Radio Model', radioModel)} + ` : ''; + + const featureSection = type === 'feature' ? ` + ${row('Use Case', usecase)} + ` : ''; + + const html = ` + + +
+
+
+ ${esc(typeLabel)} +

${esc(title.trim())}

+
+ + ${bugSection} + ${featureSection} + ${row('Additional Details', description)} +
+
+

+ Submitted via ft8af.app/contact +

+
+ +`; + + // Process image attachments + const attachments = []; + if (Array.isArray(images)) { + for (const img of images.slice(0, MAX_IMAGES)) { + if (!img || typeof img.name !== 'string' || typeof img.data !== 'string') continue; + const b64 = img.data.replace(/^data:[^;]+;base64,/, ''); + const buf = Buffer.from(b64, 'base64'); + if (buf.length > MAX_IMG_BYTES) continue; + attachments.push({ + filename: img.name.replace(/[^a-zA-Z0-9.\-_]/g, '_').slice(0, 100), + content: buf, + }); + } + } + + if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASS) { + console.warn('[contact] SMTP not configured — submission dropped'); + return res.status(503).json({ error: 'Email service not configured. Please set SMTP_HOST, SMTP_USER, and SMTP_PASS.' }); + } + + try { + const transport = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT) || 587, + secure: process.env.SMTP_SECURE === 'true', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + + await transport.sendMail({ + from: FROM, + to: TO, + subject: `[FT8AF ${typeLabel}] ${title.trim().slice(0, 120)}`, + html, + attachments, + }); + + return res.status(200).json({ ok: true }); + } catch (err) { + console.error('[contact] send failed:', err.message); + return res.status(500).json({ error: 'Failed to send. Please try again or open a GitHub issue.' }); + } +} diff --git a/build.mjs b/build.mjs index 1a88b79..9e734a9 100644 --- a/build.mjs +++ b/build.mjs @@ -96,6 +96,7 @@ function navPartial(t, ctx) { ${n('/wiki', 'wiki', ctx.navActive === 'wiki')} ${n('/faq', 'faq', ctx.navActive === 'faq')} ${n('/store', 'store', ctx.navActive === 'store')} + ${n('/contact', 'feedback', ctx.navActive === 'contact')} ${t('common.nav.github')} `; @@ -170,7 +172,8 @@ function footerPartial(t) {

${t('common.footer.project')}

${fl('https://github.com/patrickrb/FT8AF', 'repo', true)} ${fl('https://github.com/patrickrb/FT8AF/releases', 'releases', true)} - ${fl('https://github.com/patrickrb/FT8AF/issues', 'reportBug', true)} + ${fl('/contact', 'reportBug', false)} + ${fl('https://github.com/patrickrb/FT8AF/issues', 'reportBugGithub', true)} ${fl('https://github.com/N0BOY/FT8CN', 'originalFt8cn', true)} ${fl('https://sstvaf.app', 'sstvaf', true)} diff --git a/package-lock.json b/package-lock.json index ffa021e..8fd68b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,11 @@ "": { "name": "ft8af-site", "dependencies": { - "@vercel/functions": "^3.7.1" + "@vercel/functions": "^3.7.1", + "nodemailer": "^6.9.16" + }, + "engines": { + "node": ">=20" } }, "node_modules/@vercel/cli-config": { @@ -169,6 +173,15 @@ "node": ">=6" } }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", diff --git a/package.json b/package.json index d6daa34..7139038 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "check:i18n": "node .github/scripts/check-i18n.mjs" }, "dependencies": { - "@vercel/functions": "^3.7.1" + "@vercel/functions": "^3.7.1", + "nodemailer": "^6.9.16" }, "engines": { "node": ">=20" diff --git a/public/assets/contact.css b/public/assets/contact.css new file mode 100644 index 0000000..f89e4fb --- /dev/null +++ b/public/assets/contact.css @@ -0,0 +1,187 @@ +/* FT8AF — contact / feedback form */ + +.cf-wrap { max-width: 720px; margin: 0 auto; } + +/* ── Type pills ── */ +.cf-type-pills { + display: flex; + gap: 8px; + margin-bottom: 32px; +} +.cf-type-pill { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + border: 1px solid var(--border-strong); + border-radius: var(--r-md); + background: transparent; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.cf-type-pill:hover { + border-color: var(--accent); + color: var(--text); +} +.cf-type-pill.active { + background: var(--accent-soft); + border-color: var(--accent); + color: var(--accent-glow); +} + +/* ── Form fields ── */ +.cf-field { + margin-bottom: 20px; +} +.cf-label { + display: block; + font-size: 12px; + font-family: var(--font-mono); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 8px; +} +.cf-req { color: var(--accent); margin-left: 2px; } +.cf-hint { font-size: 11px; text-transform: none; letter-spacing: 0; color: var(--text-faint); margin-left: 8px; font-family: var(--font-ui); } + +.cf-input { + width: 100%; + padding: 10px 14px; + background: var(--bg-surface-2); + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + color: var(--text); + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.5; + transition: border-color 0.15s, background 0.15s; + appearance: none; + resize: vertical; +} +.cf-input::placeholder { color: var(--text-dim); } +.cf-input:focus { + outline: none; + border-color: var(--accent); + background: var(--bg-surface-3); +} +textarea.cf-input { min-height: 80px; } + +/* ── Grid rows ── */ +.cf-row-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.cf-row-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 16px; +} +@media (max-width: 640px) { + .cf-row-2, .cf-row-3 { grid-template-columns: 1fr; } +} + +/* ── File drop zone ── */ +.cf-drop { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 28px 20px; + border: 1.5px dashed var(--border-strong); + border-radius: var(--r-md); + color: var(--text-muted); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + position: relative; +} +.cf-drop:hover, .cf-drop.drag-over { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent-glow); +} +.cf-file-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} +.cf-drop-text { font-size: 14px; text-align: center; pointer-events: none; } +.cf-drop-link { color: var(--signal); text-decoration: underline; } +.cf-hint-text { font-size: 12px; color: var(--text-faint); margin-top: 8px; } + +/* ── File list ── */ +.cf-file-list { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; } +.cf-file-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--bg-surface-2); + border: 1px solid var(--border); + border-radius: var(--r-sm); + font-size: 12px; + color: var(--text-muted); + max-width: 220px; +} +.cf-file-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } +.cf-file-item-size { color: var(--text-faint); flex-shrink: 0; } +.cf-file-item-remove { + background: none; + border: none; + color: var(--text-faint); + cursor: pointer; + padding: 0; + line-height: 1; + font-size: 14px; + flex-shrink: 0; + transition: color 0.1s; +} +.cf-file-item-remove:hover { color: var(--text); } +.cf-file-err { + font-size: 12px; + color: #f87171; + margin-top: 6px; +} + +/* ── Actions ── */ +.cf-actions { + display: flex; + align-items: center; + gap: 20px; + margin-top: 28px; + flex-wrap: wrap; +} +.cf-note { font-size: 12px; color: var(--text-faint); margin: 0; } + +/* ── Result states ── */ +.cf-result { + margin-top: 24px; + padding: 18px 20px; + border-radius: var(--r-md); + font-size: 14px; + line-height: 1.6; +} +.cf-result.success { + background: rgba(74, 222, 128, 0.08); + border: 1px solid rgba(74, 222, 128, 0.25); + color: #4ade80; +} +.cf-result.error { + background: rgba(248, 113, 113, 0.08); + border: 1px solid rgba(248, 113, 113, 0.25); + color: #f87171; +} +.cf-result strong { display: block; margin-bottom: 4px; } +.cf-result a { color: inherit; text-decoration: underline; } + +/* ── Submitting state ── */ +#cf-submit[disabled] { opacity: 0.65; cursor: not-allowed; } diff --git a/public/assets/ft8af.js b/public/assets/ft8af.js index c4d00bf..aebc758 100644 --- a/public/assets/ft8af.js +++ b/public/assets/ft8af.js @@ -225,8 +225,214 @@ }); } + // ───── Contact / feedback form ───── + function initContactForm() { + var form = document.getElementById('cf'); + if (!form) return; + + var typeInput = document.getElementById('cf-type'); + var bugFields = form.querySelector('.cf-bug-fields'); + var featureFields = form.querySelector('.cf-feature-fields'); + var stepsField = document.getElementById('cf-steps'); + var actualField = document.getElementById('cf-actual'); + var usecaseField = document.getElementById('cf-usecase'); + var fileInput = document.getElementById('cf-images'); + var fileList = document.getElementById('cf-file-list'); + var submitBtn = document.getElementById('cf-submit'); + var result = document.getElementById('cf-result'); + var selectedFiles = []; + + // ── Type switching ── + form.querySelectorAll('.cf-type-pill').forEach(function (pill) { + pill.addEventListener('click', function () { + form.querySelectorAll('.cf-type-pill').forEach(function (p) { + p.classList.remove('active'); + p.setAttribute('aria-pressed', 'false'); + }); + pill.classList.add('active'); + pill.setAttribute('aria-pressed', 'true'); + var type = pill.getAttribute('data-type'); + typeInput.value = type; + if (type === 'bug') { + bugFields.style.display = ''; + featureFields.style.display = 'none'; + stepsField.setAttribute('required', ''); + actualField.setAttribute('required', ''); + if (usecaseField) usecaseField.removeAttribute('required'); + } else { + bugFields.style.display = 'none'; + featureFields.style.display = ''; + stepsField.removeAttribute('required'); + actualField.removeAttribute('required'); + if (usecaseField) usecaseField.setAttribute('required', ''); + } + hideResult(); + }); + }); + + // ── File handling ── + function formatSize(bytes) { + return bytes < 1048576 + ? (bytes / 1024).toFixed(0) + ' KB' + : (bytes / 1048576).toFixed(1) + ' MB'; + } + + function renderFileList() { + fileList.innerHTML = ''; + selectedFiles.forEach(function (f, i) { + var item = document.createElement('div'); + item.className = 'cf-file-item'; + item.innerHTML = '' + f.name + '' + + '' + formatSize(f.size) + '' + + ''; + item.querySelector('.cf-file-item-remove').addEventListener('click', function () { + selectedFiles.splice(i, 1); + renderFileList(); + }); + fileList.appendChild(item); + }); + } + + function addFiles(newFiles) { + var errEl = fileList.querySelector('.cf-file-err'); + if (errEl) errEl.remove(); + var errors = []; + Array.from(newFiles).forEach(function (f) { + if (selectedFiles.length >= 5) { + errors.push('Maximum 5 images allowed.'); + return; + } + if (f.size > 2097152) { + errors.push(f.name + ' is over 2 MB and was skipped.'); + return; + } + if (!f.type.startsWith('image/')) { + errors.push(f.name + ' is not an image and was skipped.'); + return; + } + selectedFiles.push(f); + }); + renderFileList(); + if (errors.length) { + var el = document.createElement('p'); + el.className = 'cf-file-err'; + el.textContent = errors[0]; + fileList.appendChild(el); + } + } + + if (fileInput) { + fileInput.addEventListener('change', function () { + addFiles(fileInput.files); + fileInput.value = ''; + }); + + var dropLabel = document.getElementById('cf-drop-label'); + if (dropLabel) { + dropLabel.addEventListener('dragover', function (e) { e.preventDefault(); dropLabel.classList.add('drag-over'); }); + dropLabel.addEventListener('dragleave', function () { dropLabel.classList.remove('drag-over'); }); + dropLabel.addEventListener('drop', function (e) { + e.preventDefault(); + dropLabel.classList.remove('drag-over'); + addFiles(e.dataTransfer.files); + }); + } + } + + // ── Result helpers ── + function showResult(type, title, body) { + result.className = 'cf-result ' + type; + result.innerHTML = '' + title + '' + body; + result.style.display = ''; + result.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + function hideResult() { + result.style.display = 'none'; + result.innerHTML = ''; + } + + // ── Submission ── + form.addEventListener('submit', function (e) { + e.preventDefault(); + hideResult(); + + var type = typeInput.value; + var title = (document.getElementById('cf-title').value || '').trim(); + var steps = (stepsField ? stepsField.value : '').trim(); + var actual = (actualField ? actualField.value : '').trim(); + var usecase = (usecaseField ? usecaseField.value : '').trim(); + + if (!title) { + document.getElementById('cf-title').focus(); + return; + } + if (type === 'bug' && !steps) { + stepsField.focus(); + return; + } + if (type === 'feature' && !usecase) { + if (usecaseField) usecaseField.focus(); + return; + } + + submitBtn.disabled = true; + submitBtn.textContent = 'Sending\u2026'; + + // Convert images to base64 + Promise.all(selectedFiles.map(function (f) { + return new Promise(function (resolve) { + var reader = new FileReader(); + reader.onload = function (ev) { + resolve({ name: f.name, data: ev.target.result }); + }; + reader.onerror = function () { resolve(null); }; + reader.readAsDataURL(f); + }); + })).then(function (images) { + var payload = { + type: type, + title: title, + steps: (stepsField ? stepsField.value : '').trim(), + expected: ((document.getElementById('cf-expected') || {}).value || '').trim(), + actual: actual, + usecase: usecase, + description: ((document.getElementById('cf-details') || {}).value || '').trim(), + appVersion: ((document.getElementById('cf-app-ver') || {}).value || '').trim(), + androidVersion: ((document.getElementById('cf-android-ver') || {}).value || '').trim(), + radioModel: ((document.getElementById('cf-radio') || {}).value || '').trim(), + images: images.filter(Boolean), + }; + + return fetch('/api/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + }).then(function (res) { + return res.json().then(function (data) { + if (!res.ok) throw new Error(data.error || 'Request failed'); + return data; + }); + }).then(function () { + track('contact_submit', { type: typeInput.value, page: location.pathname }); + form.reset(); + selectedFiles = []; + renderFileList(); + typeInput.value = 'bug'; + showResult('success', 'Report received.', 'Thanks \u2014 we\'ll review it and follow up if we need more details.'); + }).catch(function (err) { + showResult('error', 'Something went wrong.', + ' ' + (err.message || 'Your report could not be sent.') + + ' Please try again or open a GitHub issue.'); + }).finally(function () { + submitBtn.disabled = false; + submitBtn.textContent = 'Send Report'; + }); + }); + } + function init() { - initNav(); initReveal(); initCanvases(); initFaq(); initForms(); initAnalytics(); initLocalePref(); + initNav(); initReveal(); initCanvases(); initFaq(); initForms(); initContactForm(); initAnalytics(); initLocalePref(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); diff --git a/public/download.html b/public/download.html index dcd1698..aaa4b28 100644 --- a/public/download.html +++ b/public/download.html @@ -45,7 +45,7 @@ - + @@ -67,6 +67,7 @@ + @@ -87,9 +88,12 @@ + +
+
+
+
Also on desktop
+

Run FT8AF on your computer, too.

+
+
+ +
+

A cross-platform desktop build for Windows, macOS and Linux is now in early preview. It shares the same FT8 decoder as the Android app and covers the core loop: decode and transmit with automatic QSO sequencing, a live waterfall, rig control via Hamlib (300+ radios), FLrig or direct serial CAT, audio-device selection, and a logbook with ADIF export.

+

Preview builds are versioned desktop-* on the GitHub Releases page — grab the .dmg for macOS, .exe or .msi for Windows, or .AppImage / .deb / .rpm for Linux. The world map, POTA activation mode and PSKReporter spotting are Android-only for now.

+ + + Get the desktop preview + +
+
+
+
+
@@ -303,17 +331,22 @@

Pick your path. Work the world.