test / index.html
chaurAr's picture
Update index.html
0e7be1c verified
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Formstr Survey</title>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: #f3f4f6;
min-height: 100vh;
color: #111827;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 2.5rem 1.25rem 4rem;
}
/* ── Progress ─────────────────────────────────────── */
.progress-header {
margin-bottom: 1.75rem;
}
.progress-row {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.progress-title {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: #6b7280;
}
.progress-label {
font-size: 0.8rem;
color: #6b7280;
}
.progress-bar-track {
height: 6px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #7c3aed, #a855f7);
border-radius: 999px;
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ── Card ─────────────────────────────────────────── */
#form-card {
background: #ffffff;
border-radius: 16px;
padding: 2rem;
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.06),
0 4px 16px rgba(0, 0, 0, 0.06);
}
/* ── Loading / messages ───────────────────────────── */
.loading {
text-align: center;
padding: 3rem 0;
color: #9ca3af;
font-size: 0.95rem;
}
.loading::after {
content: "";
display: inline-block;
width: 1em;
height: 1em;
border: 2px solid #e5e7eb;
border-top-color: #7c3aed;
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
margin-left: 0.5rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-msg {
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
margin-bottom: 1rem;
}
.status-msg.error {
background: #fef2f2;
color: #b91c1c;
border: 1px solid #fca5a5;
}
.status-msg.info {
background: #ede9fe;
color: #5b21b6;
border: 1px solid #c4b5fd;
}
/* ── SDK form overrides ───────────────────────────── */
#submit-container {
display: none !important;
}
.form-section {
margin-bottom: 1.5rem;
}
.form-section:last-of-type {
margin-bottom: 0;
}
.form-name {
font-size: 1.4rem;
font-weight: 700;
margin-bottom: 0.375rem;
line-height: 1.3;
}
.form-description {
font-size: 0.9rem;
color: #6b7280;
line-height: 1.6;
}
.section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
color: #374151;
}
.section-description {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 1rem;
}
/* Text fields */
form label {
display: block;
font-size: 0.9rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.35rem;
}
form input[type="text"],
form textarea {
width: 100%;
padding: 0.6rem 0.875rem;
border: 1.5px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
color: #111827;
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
margin-bottom: 1rem;
}
form input[type="text"]:focus,
form textarea:focus {
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12);
}
form input[type="text"]:disabled {
background: #f9fafb;
border-color: #e5e7eb;
color: #9ca3af;
cursor: not-allowed;
font-size: 0.8rem;
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ── Action row ───────────────────────────────────── */
.action-row {
margin-top: 1.75rem;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 1rem;
}
.next-btn {
padding: 0.65rem 1.75rem;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition:
background 0.15s,
opacity 0.15s;
line-height: 1;
}
.next-btn:hover:not(:disabled) {
background: #6d28d9;
}
.next-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
/* ── Done screen ──────────────────────────────────── */
.done-card {
text-align: center;
padding: 2.5rem 1rem;
}
.done-icon {
font-size: 3.5rem;
margin-bottom: 1rem;
}
.done-title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 0.6rem;
}
.done-sub {
color: #6b7280;
font-size: 0.95rem;
}
.done-actions {
margin-top: 1.5rem;
}
.done-close {
margin-top: 1rem;
color: #9ca3af;
font-size: 0.85rem;
}
/* ── Language picker ─────────────────────────────── */
.lang-picker-title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.375rem;
}
.lang-picker-sub {
font-size: 0.9rem;
color: #6b7280;
margin-bottom: 1.5rem;
}
.lang-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
.lang-btn {
padding: 1rem;
border: 2px solid #e5e7eb;
border-radius: 10px;
background: #fff;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
color 0.15s;
text-align: center;
}
.lang-btn:hover {
border-color: #7c3aed;
background: #faf5ff;
color: #7c3aed;
}
/* ── Audio player ────────────────────────────────── */
.audio-block {
margin: 1.25rem 0 1.5rem;
}
.audio-block audio {
width: 100%;
border-radius: 8px;
}
/* ── Grid field ──────────────────────────────────── */
.grid-field {
margin-bottom: 1.25rem;
}
.grid-wrapper {
overflow-x: auto;
}
.grid-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.grid-table th {
text-align: center;
padding: 0.4rem 0.6rem;
color: #6b7280;
font-weight: 600;
border-bottom: 1.5px solid #e5e7eb;
}
.grid-table td {
text-align: center;
padding: 0.45rem 0.5rem;
border-bottom: 1px solid #f3f4f6;
}
.grid-table td.grid-row-label {
text-align: left;
color: #374151;
font-weight: 500;
padding-left: 0;
min-width: 140px;
}
.grid-table input[type="radio"] {
accent-color: #7c3aed;
width: 1rem;
height: 1rem;
cursor: pointer;
}
/* Option / radio groups */
.option-group {
margin-bottom: 1.25rem;
}
.option-label {
font-size: 0.9rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.6rem;
}
.option-group label {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.55rem 0.75rem;
border: 1.5px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 400;
color: #374151;
margin-bottom: 0.4rem;
transition:
border-color 0.15s,
background 0.15s;
}
.option-group label:hover {
border-color: #a78bfa;
background: #faf5ff;
}
.option-group input[type="radio"] {
accent-color: #7c3aed;
width: 1rem;
height: 1rem;
flex-shrink: 0;
margin: 0;
}
/* ── Disclaimer ──────────────────────────────────── */
.disclaimer-section {
padding-bottom: 1.5rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.survey-description {
margin-bottom: 0.25rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
.disclaimer-heading {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #9ca3af;
margin-bottom: 0.6rem;
}
.disclaimer {
font-size: 0.9rem;
color: #374151;
line-height: 1.7;
white-space: pre-line;
}
/* ── Spinner for submitting state ─────────────────── */
.spinner {
display: inline-block;
width: 1em;
height: 1em;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.7s linear infinite;
vertical-align: middle;
margin-right: 0.4rem;
}
/* ── Footer ───────────────────────────────────────── */
.form-footer {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
text-align: center;
font-size: 0.8rem;
color: #9ca3af;
}
.form-footer a {
color: #7c3aed;
text-decoration: none;
}
.form-footer a:hover {
text-decoration: underline;
}
/* ── Markdown list styling ─────────────────────────── */
.form-body p ul,
.form-body p ol {
margin: 0.5rem 0 0.5rem 1.5rem;
padding-left: 0.5rem;
}
.form-body p li {
margin-bottom: 0.25rem;
line-height: 1.5;
}
</style>
</head>
<body>
<div class="container">
<div class="progress-header" id="progress-header" style="display: none">
<div class="progress-row">
<span class="progress-title">Progress</span>
<span class="progress-label" id="progress-label"
>0 / 10 responses submitted</span
>
</div>
<div class="progress-bar-track">
<div
class="progress-bar-fill"
id="progress-fill"
style="width: 0%"
></div>
</div>
</div>
<div id="form-card"></div>
</div>
<script type="module">
import { FormstrSDK } from "https://esm.sh/@formstr/sdk";
import {
SimplePool,
nip19,
nip44,
} from "https://esm.sh/nostr-tools@2.3.2";
import { marked } from "https://esm.sh/marked@12.0.0";
// Parse markdown string to HTML
function parseMarkdown(text) {
if (!text) return "";
return marked.parse(text);
}
// ── Edit this disclaimer to update what's shown on the language selection screen ──
const DISCLAIMER = `By participating in this survey, you agree that your anonymized responses may be used for academic research as part of a master's thesis. No personally identifiable information will be collected.\n\nPlease select only one language that you are a native speaker of or fluent in at a bilingual level. <strong style="color:#dc2626;">⚠️ Do not proceed if you are not proficient in any of the listed languages, as inaccurate responses may affect the validity of the survey results.</strong>\n\nEach audio clip you hear may not exceed 30 seconds.\n\nIf you speak multiple languages, please complete the survey for one language first. You will have the option to select another language and complete a new survey afterward.`;
// ─────────────────────────────────────────────────────────────────────────────────
// ── HuggingFace dataset configuration ───────────────────────────────────────────
const HF_DATASET = "chaurAr/crosslingual-asr-entity-benchmark";
// ─────────────────────────────────────────────────────────────────────────────────
const METADATA_URL= 'https://huggingface.co/datasets/chaurAr/crosslingual-asr-entity-benchmark/raw/main/metadata.csv'
// Map language names to CSV language codes
const LANGUAGE_CODE_MAP = {
"French": "fr",
"German": "de",
"Spanish": "es",
"Italian": "it",
};
const TOTAL = 10;
const NADDR =
"naddr1qvzqqqr4mqpzqgqewrpkjx0nx68trtvh5hh7n3ed8hc0zdmvj388us6geknfjtz0qythwumn8ghj7un9d3shjtnswf5k6ctv9ehx2ap0qy88wumn8ghj7mn0wvhxcmmv9uq3uamnwvaz7tmjv4kxz7fwdehhxarj9emkjun9v3hx2apwdfcz7qgawaehxw309ahx7um5wgknqvfw09skk6tgdahxuefwvdhk6tcqqeshyn3ewdfq7r75cv";
const VIEW_KEY =
"a68710cefc1607225f0d754e054d2e5e49dca7ca74e6f1c1698a647a9a8f9c16";
const DEFAULT_RELAYS = [
"wss://relay.damus.io/",
"wss://relay.primal.net/",
"wss://nos.lol",
"wss://relay.nostr.band",
"wss://relay.snort.social",
];
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2)
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);
return bytes;
}
// Work around SDK bug: fetchFormWithViewKey passes viewKey as string to
// nip44.v2.utils.getConversationKey which requires Uint8Array.
async function fetchFormManually(naddr, viewKeyHex) {
const sdk = new FormstrSDK();
const pool = new SimplePool();
const decoded = nip19.decode(naddr).data;
const { pubkey, identifier, relays } = decoded;
const relayList = relays?.length ? relays : DEFAULT_RELAYS;
const event = await pool.get(relayList, {
kinds: [30168],
authors: [pubkey],
"#d": [identifier],
});
if (!event) throw new Error("Form event not found on relays");
// Unencrypted form
if (event.content === "") {
const tags = [...event.tags, ["pubkey", event.pubkey]];
return sdk.normalizeForm(tags);
}
// Encrypted form — convert hex viewKey to bytes before decryption
const viewKeyBytes = hexToBytes(viewKeyHex);
const convKey = nip44.v2.utils.getConversationKey(
viewKeyBytes,
event.pubkey,
);
const decrypted = nip44.v2.decrypt(event.content, convKey);
const decryptedTags = JSON.parse(decrypted);
const relayTags = event.tags.filter((t) => t[0] === "relay");
decryptedTags.push(...relayTags, ["pubkey", event.pubkey]);
// Collect raw data for field types the SDK can't handle (e.g. grid),
// keyed by fieldId, before we sanitize tags for normalizeForm.
// optionsVal is a JSON *string* whose parsed value may be an object, not array.
const rawFieldExtras = {};
decryptedTags.forEach((tag) => {
if (tag[0] !== "field") return;
const [, fieldId, type, label, optionsVal, configVal] = tag;
let parsedOptions = null;
try {
parsedOptions = JSON.parse(optionsVal);
} catch {}
if (
parsedOptions !== null &&
!Array.isArray(parsedOptions) &&
typeof parsedOptions === "object"
) {
let parsedConfig = {};
try {
parsedConfig = JSON.parse(configVal);
} catch {}
rawFieldExtras[fieldId] = {
type,
label,
options: parsedOptions,
config: parsedConfig,
};
}
});
// normalizeForm expects optionsStr (index 4) as a JSON array string or falsy.
// Grid fields store an object there; null them out so the SDK doesn't crash.
const normalizedTags = decryptedTags.map((tag) => {
if (tag[0] !== "field") return tag;
return tag.map((val, idx) => {
if (idx < 4) return val;
const str =
val !== null && typeof val !== "string"
? JSON.stringify(val)
: val;
if (idx === 4) {
try {
if (!Array.isArray(JSON.parse(str))) return null;
} catch {
return null;
}
}
return str;
});
});
const form = sdk.normalizeForm(normalizedTags);
form._rawFieldExtras = rawFieldExtras;
return form;
}
const sdk = new FormstrSDK();
const card = document.getElementById("form-card");
// Persistent browser-scoped ID sent in every response
const SESSION_ID_KEY = "formstr_session_id";
function getOrCreateSessionId() {
let id = localStorage.getItem(SESSION_ID_KEY);
if (!id) {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
id = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
"",
);
localStorage.setItem(SESSION_ID_KEY, id);
}
return id;
}
const SESSION_ID = getOrCreateSessionId();
function applyUniqueIdField() {
const field = Object.values(formDef.fields).find(
(f) => f.labelHtml.trim() === "Unique Id",
);
if (!field) return;
const input = document.querySelector(`input[name="${field.id}"]`);
if (!input) return;
input.value = SESSION_ID;
// Hide the label immediately before the input and the input itself
const label = input.previousElementSibling;
if (label?.tagName === "LABEL") label.style.display = "none";
input.style.display = "none";
}
function applyLanguageField() {
const field = Object.values(formDef.fields).find(
(f) => f.labelHtml.trim().toLowerCase() === "language",
);
if (!field) return;
const input = document.querySelector(`input[name="${field.id}"]`);
if (!input) return;
input.value = selectedLanguage;
// Hide the label immediately before the input and the input itself
const label = input.previousElementSibling;
if (label?.tagName === "LABEL") label.style.display = "none";
input.style.display = "none";
}
let formDef = null;
let submitted = 0;
let selectedLanguage = null;
let audioSamples = [];
// Parse CSV line handling quoted values
function parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
// Fetch metadata CSV and filter by language, then randomly sample 10 files
async function fetchAudioSamples(language) {
const langCode = LANGUAGE_CODE_MAP[language];
if (!langCode) {
throw new Error(`Unknown language: ${language}`);
}
const response = await fetch(METADATA_URL);
if (!response.ok) {
throw new Error(`Failed to fetch metadata: ${response.statusText}`);
}
const csvText = await response.text();
const lines = csvText.trim().split("\n");
// Skip header line, parse CSV
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const parts = parseCSVLine(line);
if (parts.length < 3) continue;
const file = parts[0];
const lang = parts[1];
const namedEntity = parts[2];
if (lang === langCode) {
files.push({ file, namedEntity });
}
}
if (files.length === 0) {
throw new Error(`No audio files found for language: ${language}`);
}
// Shuffle and take first 10 (or all if less than 10)
const shuffled = files.sort(() => Math.random() - 0.5);
return shuffled.slice(0, TOTAL);
}
// Generate HuggingFace URL for an audio file
function getAudioUrl(audioSample) {
const filePath = audioSample.file;
const fileName = filePath.split("/").pop();
return `https://huggingface.co/datasets/${HF_DATASET}/resolve/main/${filePath}`;
}
// Get named entity for current audio sample
function getNamedEntity() {
const audioSample = audioSamples[submitted];
return audioSample?.namedEntity || '';
}
function updateProgress() {
document.getElementById("progress-label").textContent =
`${submitted} / ${TOTAL} responses submitted`;
document.getElementById("progress-fill").style.width =
`${(submitted / TOTAL) * 100}%`;
}
function applyAudioFields(audioUrl) {
Object.values(formDef.fields).forEach((field) => {
if (!field.labelHtml.includes("Audio")) return;
const input = document.querySelector(`input[name="${field.id}"]`);
if (!input) return;
input.value = audioUrl;
//input.disabled = true;
input.style.display = "none";
const label = input.previousElementSibling;
if (label?.tagName === "LABEL") label.style.display = "none";
});
}
function renderGridField(fieldId, raw, namedEntity = '') {
const { label, options: g } = raw;
// Format: { columns: [[id, label, configStr], ...], rows: [[id, label, configStr], ...] }
const rowsRaw = g.rows ?? [];
const colsRaw = g.columns ?? g.cols ?? [];
const norm = (items) =>
items.map((item) =>
Array.isArray(item)
? { id: String(item[0]), label: String(item[1]) }
: {
id: String(item.id ?? item),
label: String(item.label ?? item),
},
);
const rows = norm(rowsRaw);
const cols = norm(colsRaw);
const n = cols.length;
// Value goes from 1 (leftmost column) to N (rightmost column)
const headerCells = cols.map((c) => `<th>${c.label}</th>`).join("");
const bodyRows = rows
.map(
(r, index) => {
let rowLabel = r.label;
// Add named entity to the 4th row (index 3)
if (index === 2 && namedEntity) {
rowLabel += ` <em>"<strong style="color:#6b7280;">${namedEntity}</strong>"</em>?`;
}
return `
<tr>
<td class="grid-row-label">${rowLabel}</td>
${cols.map((c, i) => `<td><input type="radio" name="${fieldId}__${r.id}" value="${i + 1}"></td>`).join("")}
</tr>`;
},
)
.join("");
return `
<div class="grid-field">
<div class="option-label">${label}</div>
<div class="grid-wrapper">
<table class="grid-table">
<thead><tr><th></th>${headerCells}</tr></thead>
<tbody>${bodyRows}</tbody>
</table>
</div>
</div>`;
}
function renderForm() {
updateProgress();
if (submitted >= TOTAL) {
card.innerHTML = `
<div class="done-card">
<div class="done-icon">🎉</div>
<div class="done-title">All done!</div>
<div class="done-sub">All 10 responses have been submitted. Thank you!<br /><br />If you are a native or near-native speaker of a <strong>different language</strong> than the one you just completed, you can continue with another language.</div>
<div class="done-actions">
<button class="next-btn" id="restart-btn">Continue with Another Language</button>
</div>
<div class="done-close">Otherwise, you may close this page now.</div>
</div>
`;
document.getElementById("restart-btn").addEventListener("click", () => {
submitted = 0;
selectedLanguage = null;
audioSamples = [];
document.getElementById("progress-header").style.display = "none";
showLanguagePicker();
});
return;
}
sdk.renderHtml(formDef);
const isLast = submitted === TOTAL - 1;
const formHtml = formDef.html.form.replace(
/\[Audio\]/g,
`${selectedLanguage} Audio will come here`,
);
card.innerHTML = `
<div id="status-msg" class="status-msg" style="display:none"></div>
${formHtml}
<div class="action-row">
<button class="next-btn" id="next-btn">
${isLast ? "Submit" : "Next →"}
</button>
</div>
<div class="form-footer">
Powered by <a href="https://about.formstr.app/" target="_blank" rel="noopener">form*</a>
</div>
`;
applyUniqueIdField();
applyLanguageField();
// Render markdown in question labels
document.querySelectorAll(`#form-${formDef.id} p`).forEach((label) => {
const originalText = label.getAttribute('data-original') || label.innerHTML;
if (!label.getAttribute('data-original')) {
label.setAttribute('data-original', originalText);
}
//console.log(originalText)
// Check if the label contains markdown syntax
if (originalText.includes('**') || originalText.includes('*') || originalText.includes('`') || originalText.includes('#')) {
label.innerHTML = parseMarkdown(originalText);
}
});
// Insert audio player after the form intro (name/description) block
// Use the pre-sampled audio files based on current submission count
const audioFile = audioSamples[submitted];
const audioUrl = getAudioUrl(audioFile);
//console.log(audioUrl)
applyAudioFields(audioUrl);
const introSection = document.querySelector(
`#form-${formDef.id} .form-intro`,
);
if (introSection) {
introSection.insertAdjacentHTML(
"afterend",
`
<div class="audio-block">
<audio controls src="${audioUrl}"></audio>
</div>
`,
);
}
// Inject custom grid fields in their correct fieldOrder positions.
// The SDK rendered them as empty strings; find their placeholders and replace.
const extras = formDef._rawFieldExtras ?? {};
const formBody = document.querySelector(
`#form-${formDef.id} .form-body`,
);
// First, collect all grid field HTML
let gridFieldsHtml = '';
Object.entries(extras).forEach(([fieldId, raw]) => {
const namedEntity = getNamedEntity();
gridFieldsHtml += renderGridField(fieldId, raw, namedEntity);
});
// Find all option-group elements and move them after grid fields
if (formBody) {
const optionGroups = formBody.querySelectorAll('.option-group');
const optionGroupsArray = Array.from(optionGroups);
// Remove option-groups from their current position
optionGroupsArray.forEach(og => og.remove());
// Insert grid fields first, then option-groups
formBody.insertAdjacentHTML("beforeend", gridFieldsHtml);
optionGroupsArray.forEach(og => formBody.appendChild(og));
// Find the optional comments field (label containing "additional comments" with input right after)
const allLabels = formBody.querySelectorAll('label');
allLabels.forEach(label => {
if (label.textContent.includes('additional comments')) {
// Check if the next sibling is an input or textarea
const nextSibling = label.nextElementSibling;
if (nextSibling && (nextSibling.tagName === 'INPUT' || nextSibling.tagName === 'TEXTAREA')) {
// Create a container for label + input
const container = document.createElement('div');
container.appendChild(label);
container.appendChild(nextSibling);
formBody.appendChild(container);
}
}
});
}
// Validate that all required fields are answered
function validateForm() {
const missingFields = [];
// Check grid fields - each row needs a radio button selected
const gridTables = document.querySelectorAll('.grid-table');
gridTables.forEach(table => {
const rows = table.querySelectorAll('tbody tr');
rows.forEach(row => {
const radios = row.querySelectorAll('input[type="radio"]');
const isAnswered = Array.from(radios).some(r => r.checked);
if (!isAnswered) {
const labelCell = row.querySelector('.grid-row-label');
const fieldLabel = labelCell ? labelCell.textContent.trim() : 'Grid question';
missingFields.push(fieldLabel);
}
});
});
// Check option groups (radio button groups)
const optionGroups = document.querySelectorAll('.option-group');
optionGroups.forEach(og => {
const radios = og.querySelectorAll('input[type="radio"]');
const isAnswered = Array.from(radios).some(r => r.checked);
if (!isAnswered) {
const label = og.querySelector('.option-label');
const fieldLabel = label ? label.textContent.trim() : 'Option question';
missingFields.push(fieldLabel);
}
});
return missingFields;
}
// Show popup notification
function showValidationError() {
alert("Please answer all questions before moving forward");
}
document
.getElementById("next-btn")
.addEventListener("click", async () => {
const btn = document.getElementById("next-btn");
const statusEl = document.getElementById("status-msg");
// Validate all required fields before submitting
const missingFields = validateForm();
if (missingFields.length > 0) {
showValidationError();
return;
}
btn.disabled = true;
btn.innerHTML = `<span class="spinner"></span>${isLast ? "Submitting…" : "Saving…"}`;
statusEl.style.display = "none";
const formEl = document.getElementById(`form-${formDef.id}`);
const fd = new FormData(formEl);
const values = {};
fd.forEach((v, k) => {
values[k] = v;
});
try {
await sdk.submit(formDef, values);
submitted++;
renderForm();
} catch (err) {
console.error("Submission failed:", err);
statusEl.textContent = "Submission failed — please try again.";
statusEl.className = "status-msg error";
statusEl.style.display = "block";
btn.disabled = false;
btn.textContent = isLast ? "Submit" : "Next →";
}
});
}
const SURVEY_DES = "In this survey, you will evaluate <strong>10 audio samples</strong>. Each audio clip contains few sentences in your selected language with an embedded English-language entity (e.g., a name or term). You will rate the overall audio quality as well as how naturally this entity is pronounced within the sentence. For each audio, you need to answer <strong>4 questions</strong> related to 3 aspects:\n\n<strong>Pronunciation</strong> – How clearly and correctly the words are pronounced\n<strong>Grammar</strong> – How grammatically correct the speech sounds\n<strong>Naturalness</strong> – How natural the overall speech sounds\n\n<strong>Rating Scale:</strong> 1 (Poor) to 5 (Excellent)\n\nPlease listen to each audio carefully before providing your ratings.";
function showLanguagePicker() {
const languages = ["French", "German", "Italian", "Spanish"];
card.innerHTML = `
<div class="disclaimer-section">
<div class="disclaimer-heading">Before you begin</div>
<div class="disclaimer">${DISCLAIMER}</div>
</div>
<div class="survey-description">
<div class="disclaimer-heading">About this Survey</div>
<div class="disclaimer">
${SURVEY_DES}
</div>
</div>
<div class="lang-picker-title">Choose a language</div>
<div class="lang-picker-sub">Select the language for this survey session.</div>
<div class="lang-grid">
${languages.map((l) => `<button class="lang-btn" data-lang="${l}">${l}</button>`).join("")}
</div>
`;
card.querySelectorAll(".lang-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
selectedLanguage = btn.dataset.lang;
card.innerHTML = `<div class="loading">Loading audio samples...</div>`;
document.getElementById("progress-header").style.display = "";
try {
// Fetch and sample audio files for the selected language
audioSamples = await fetchAudioSamples(selectedLanguage);
formDef = await fetchFormManually(NADDR, VIEW_KEY);
renderForm();
} catch (err) {
console.error("Failed to load:", err);
card.innerHTML = `<div class="status-msg error">Failed to load: ${err.message}</div>`;
}
});
});
}
showLanguagePicker();
</script>
</body>
</html>