Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions api/contact.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import nodemailer from 'nodemailer';

const TO = process.env.CONTACT_TO || 'k1af@ft8af.app';
const FROM = process.env.SMTP_FROM || 'FT8AF <noreply@ft8af.app>';
const MAX_IMAGES = 5;
const MAX_IMG_BYTES = 2_097_152; // 2 MB per image

function esc(str) {
return String(str ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

function row(label, value) {
if (!value || !String(value).trim()) return '';
const formatted = String(value).replace(/\n/g, '<br>');
return `<tr>
<th style="text-align:left;padding:8px 12px;background:#1a1d2e;color:#8a9bb5;white-space:nowrap;vertical-align:top;font-size:12px;text-transform:uppercase;letter-spacing:.06em">${esc(label)}</th>
<td style="padding:8px 12px;color:#cdd6f4;font-size:14px;vertical-align:top">${esc(String(value)).replace(/\n/g, '<br>')}</td>
</tr>`;
}

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 = `<!doctype html>
<html>
<body style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0d0f17;color:#cdd6f4;margin:0;padding:24px">
<div style="max-width:680px;margin:0 auto">
<div style="background:#161925;border:1px solid #2a2f45;border-radius:8px;overflow:hidden">
<div style="padding:20px 24px;background:#1e2235;border-bottom:1px solid #2a2f45">
<span style="font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:#6e7fa3">${esc(typeLabel)}</span>
<h1 style="margin:6px 0 0;font-size:20px;color:#fff">${esc(title.trim())}</h1>
</div>
<table style="border-collapse:collapse;width:100%">
${bugSection}
${featureSection}
${row('Additional Details', description)}
</table>
</div>
<p style="color:#4a5568;font-size:11px;margin-top:16px;text-align:center">
Submitted via <a href="https://ft8af.app/contact" style="color:#6e7fa3">ft8af.app/contact</a>
</p>
</div>
</body>
</html>`;

// 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.' });
}
}
5 changes: 4 additions & 1 deletion build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')}
<a href="https://github.com/patrickrb/FT8AF" target="_blank" rel="noopener">${t('common.nav.github')}</a>
</div>
<div class="nav-cta">
Expand All @@ -118,6 +119,7 @@ function navPartial(t, ctx) {
<a href="/wiki">${t('common.nav.wiki')}</a>
<a href="/faq">${t('common.nav.faq')}</a>
<a href="/store">${t('common.nav.store')}</a>
<a href="/contact">${t('common.nav.feedback')}</a>
<a href="https://github.com/patrickrb/FT8AF" target="_blank" rel="noopener">${t('common.nav.github')}</a>
</div>
</nav>`;
Expand Down Expand Up @@ -170,7 +172,8 @@ function footerPartial(t) {
<h4>${t('common.footer.project')}</h4>
${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)}
</div>
Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"@neondatabase/serverless": "1.1.0",
"@vercel/functions": "^3.7.1",
"nodemailer": "^6.9.16",
"qrcode-generator": "2.0.4"
},
"engines": {
Expand Down
187 changes: 187 additions & 0 deletions public/assets/contact.css
Original file line number Diff line number Diff line change
@@ -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; }
Loading
Loading