Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped
624 lines
27 KiB
HTML
624 lines
27 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>{{ form_title }}</title>
|
|
<meta name="robots" content="noindex" />
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
<link
|
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
|
rel="stylesheet"
|
|
/>
|
|
<style>
|
|
:root {
|
|
/* espresso / frappe-ui light-theme tokens (pure-neutral grays) */
|
|
--ink-strong: oklch(0.168 0 0); /* ink-gray-9 — headings */
|
|
--ink: oklch(0.205 0 0); /* ink-gray-8 — field text */
|
|
--muted: oklch(0.586 0 0); /* ink-gray-5 — labels */
|
|
--faint: oklch(0.683 0 0); /* ink-gray-4 — placeholders */
|
|
--border: oklch(0.913 0 0); /* outline-gray-2 */
|
|
--border-focus: oklch(0.83 0 0); /* outline-gray-3 */
|
|
--bg: oklch(0.964 0 0); /* surface-gray-2 — page */
|
|
--card: oklch(1 0 0); /* white surface */
|
|
--field: oklch(0.964 0 0); /* surface-gray-2 — inputs */
|
|
--accent: oklch(0.205 0 0); /* surface-gray-10 — button */
|
|
--accent-hover: oklch(0.341 0 0); /* surface-gray-9 */
|
|
--danger: oklch(0.507 0.165 25.286); /* ink-red-5 */
|
|
--danger-bg: oklch(0.978 0.011 17.34); /* surface-red-1 */
|
|
--success-bg: oklch(0.965 0.03 155.987); /* surface-green-2 */
|
|
--success-ink: oklch(0.506 0.118 155.358); /* ink-green-5 */
|
|
--ring: oklch(0.586 0 0 / 0.15);
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; padding: 0; }
|
|
body {
|
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--ink);
|
|
min-height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: flex-start;
|
|
padding: 48px 16px;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
.card {
|
|
width: 100%;
|
|
max-width: 560px;
|
|
background: var(--card);
|
|
border: 1px solid var(--border);
|
|
border-radius: 14px;
|
|
padding: 28px 28px 26px;
|
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 12px 32px rgba(0, 0, 0, 0.05);
|
|
}
|
|
/* embed mode (?embed=1): drop the page chrome so the form sits flush inside
|
|
the host page — no gray backdrop, no card border/shadow, full width */
|
|
body.embed { background: transparent; padding: 0; min-height: 0; }
|
|
body.embed .card { max-width: none; border: none; border-radius: 0; box-shadow: none; padding: 0; }
|
|
body.embed .draft-banner { max-width: none; }
|
|
.title { font-size: 20px; font-weight: 600; color: var(--ink-strong); margin: 0 0 14px; letter-spacing: -0.01em; line-height: 1.3; }
|
|
/* with no description, the title supplies the gap the subtitle normally would */
|
|
.title.title-only { margin-bottom: 22px; }
|
|
.subtitle { font-size: 14px; color: var(--muted); margin: 0 0 22px; line-height: 1.5; white-space: pre-wrap; }
|
|
.section { margin-bottom: 4px; }
|
|
.section-title { font-size: 15px; font-weight: 600; color: var(--ink-strong); margin: 20px 0 12px; }
|
|
.section:first-child .section-title { margin-top: 0; }
|
|
.cols { display: grid; gap: 0 20px; }
|
|
@media (max-width: 520px) { .cols { grid-template-columns: 1fr !important; } }
|
|
.field { margin-bottom: 16px; }
|
|
.field label { display: block; font-size: 13px; font-weight: 400; margin-bottom: 6px; color: var(--muted); }
|
|
.field .req { color: var(--danger); margin-left: 2px; }
|
|
.field .desc { font-size: 12px; color: var(--muted); margin-top: 5px; line-height: 1.4; }
|
|
input[type="text"], input[type="email"], input[type="tel"], input[type="number"],
|
|
input[type="date"], input[type="datetime-local"], textarea, select {
|
|
width: 100%;
|
|
font: inherit;
|
|
font-size: 14px;
|
|
line-height: 1.4;
|
|
color: var(--ink);
|
|
background: var(--field);
|
|
border: 1px solid transparent;
|
|
border-radius: 8px;
|
|
padding: 6px 10px;
|
|
outline: none;
|
|
transition: border-color 0.12s, box-shadow 0.12s, background 0.12s;
|
|
}
|
|
::placeholder { color: var(--faint); }
|
|
textarea { min-height: 68px; resize: vertical; }
|
|
select {
|
|
appearance: none;
|
|
-webkit-appearance: none;
|
|
-moz-appearance: none;
|
|
padding-right: 32px;
|
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%237c7c7c' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
|
background-repeat: no-repeat;
|
|
background-position: right 10px center;
|
|
cursor: pointer;
|
|
}
|
|
input:focus, textarea:focus, select:focus {
|
|
border-color: var(--border-focus);
|
|
box-shadow: 0 0 0 2px var(--ring);
|
|
background: var(--card);
|
|
}
|
|
.has-error, .has-error:focus {
|
|
border-color: var(--danger);
|
|
box-shadow: 0 0 0 2px var(--danger-bg);
|
|
}
|
|
.check { display: flex; align-items: center; gap: 9px; }
|
|
.check input { width: 16px; height: 16px; accent-color: var(--accent); }
|
|
.check label { margin: 0; font-weight: 400; font-size: 14px; }
|
|
.actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
|
|
button.submit, button.discard {
|
|
font: inherit;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
border: none;
|
|
border-radius: 8px;
|
|
padding: 8px 18px;
|
|
cursor: pointer;
|
|
transition: background 0.12s, transform 0.02s;
|
|
}
|
|
button.submit { color: oklch(1 0 0); background: var(--accent); }
|
|
button.submit:hover { background: var(--accent-hover); }
|
|
button.submit:active { transform: translateY(1px); }
|
|
button.submit:disabled { opacity: 0.5; cursor: default; }
|
|
button.discard { color: var(--ink); background: var(--field); }
|
|
button.discard:hover { background: var(--border); }
|
|
.form-error {
|
|
display: none;
|
|
background: var(--danger-bg);
|
|
color: var(--danger);
|
|
font-size: 13px;
|
|
border-radius: 9px;
|
|
padding: 10px 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
/* inline confirmation after an embedded submit (form stays, just clears) */
|
|
.form-ok {
|
|
display: none;
|
|
background: var(--success-bg);
|
|
color: var(--success-ink);
|
|
font-size: 13px;
|
|
border-radius: 9px;
|
|
padding: 10px 12px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.success { text-align: center; padding: 20px 4px 8px; }
|
|
.success .check-ring {
|
|
width: 48px; height: 48px; border-radius: 999px; margin: 0 auto 16px;
|
|
background: var(--success-bg); color: var(--success-ink); display: flex; align-items: center;
|
|
justify-content: center; font-size: 24px;
|
|
}
|
|
.success h2 { font-size: 19px; color: var(--ink-strong); margin: 0 0 6px; }
|
|
.success p { color: var(--muted); font-size: 14px; margin: 0; line-height: 1.5; }
|
|
.redirect-note { margin-top: 12px !important; font-size: 13px; }
|
|
.redirect-note a { color: var(--ink-strong); text-decoration: underline; margin-left: 4px; }
|
|
/* frappe-ui Alert — yellow, subtle: surface-amber-2 bg, amber triangle-alert
|
|
icon, ink-gray-9 text, rounded-md, px-4 py-3.5, gap-3, text-base */
|
|
.draft-banner {
|
|
display: flex; align-items: flex-start; gap: 12px;
|
|
width: 100%; max-width: 560px; margin: 0 auto 12px;
|
|
padding: 14px 16px; border-radius: 6px;
|
|
background: oklch(0.967 0.045 91.733); /* surface-amber-2 */
|
|
color: var(--ink-strong); /* ink-gray-9 */
|
|
font: 400 14px/1.5 "Inter", sans-serif; text-align: left;
|
|
}
|
|
.draft-banner svg { flex-shrink: 0; width: 16px; height: 16px; margin-top: 1px; color: oklch(0.599 0.141 56.803); }
|
|
</style>
|
|
</head>
|
|
<body class="{% if embed %}embed{% endif %}">
|
|
{% if draft_preview %}
|
|
<div class="draft-banner">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
|
|
<span>{{ _("Draft preview — only you can see this. Publish the form to make it public.") }}</span>
|
|
</div>
|
|
{% endif %}
|
|
<div class="card">
|
|
<div id="form-view">
|
|
<h1 class="title{% if not form_description %} title-only{% endif %}">{{ form_title }}</h1>
|
|
{% if form_description %}<p class="subtitle">{{ form_description }}</p>{% endif %}
|
|
|
|
<div class="form-error" id="form-error"></div>
|
|
<div class="form-ok" id="form-ok"></div>
|
|
|
|
<form id="web-form" novalidate>
|
|
{% for section in layout %}
|
|
<div class="section">
|
|
{% if section.label %}<div class="section-title">{{ section.label }}</div>{% endif %}
|
|
<div class="cols" style="grid-template-columns: repeat({{ section.columns|length }}, minmax(0, 1fr))">
|
|
{% for col in section.columns %}
|
|
<div class="col">
|
|
{% for f in col %}
|
|
<div class="field">
|
|
{% if f.fieldtype == "Check" %}
|
|
<div class="check">
|
|
<input type="checkbox" id="{{ f.fieldname }}" name="{{ f.fieldname }}" />
|
|
<label for="{{ f.fieldname }}">{{ f.label }}{% if f.reqd or f.mandatory_depends_on %}<span class="req"{% if not f.reqd %} style="display: none"{% endif %}>*</span>{% endif %}</label>
|
|
</div>
|
|
{% else %}
|
|
<label for="{{ f.fieldname }}">{{ f.label }}{% if f.reqd or f.mandatory_depends_on %}<span class="req"{% if not f.reqd %} style="display: none"{% endif %}>*</span>{% endif %}</label>
|
|
{% if f.fieldtype in ("Small Text", "Text", "Long Text", "Text Editor", "HTML Editor", "Markdown Editor") %}
|
|
<textarea id="{{ f.fieldname }}" name="{{ f.fieldname }}" placeholder="{{ f.placeholder }}" {% if f.reqd %}required{% endif %}></textarea>
|
|
{% elif f.fieldtype == "Select" %}
|
|
<select id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %}>
|
|
<option value="">Select…</option>
|
|
{% for opt in f.options.split("\n") %}{% if opt %}<option value="{{ opt }}">{{ opt }}</option>{% endif %}{% endfor %}
|
|
</select>
|
|
{% elif f.fieldtype == "Link" %}
|
|
<select id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %}>
|
|
<option value="">{{ f.placeholder or "Select…" }}</option>
|
|
{% for opt in link_options.get(f.fieldname, []) %}<option value="{{ opt.value }}">{{ opt.label }}</option>{% endfor %}
|
|
</select>
|
|
{% elif f.fieldtype in ("Int", "Float", "Currency", "Percent") %}
|
|
<input type="number" id="{{ f.fieldname }}" name="{{ f.fieldname }}" placeholder="{{ f.placeholder }}" {% if f.reqd %}required{% endif %} />
|
|
{% elif f.fieldtype == "Date" %}
|
|
<input type="date" id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %} />
|
|
{% elif f.fieldtype == "Datetime" %}
|
|
<input type="datetime-local" id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %} />
|
|
{% elif f.fieldtype == "Time" %}
|
|
<input type="time" id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %} />
|
|
{% elif f.fieldtype == "Color" %}
|
|
<input type="color" id="{{ f.fieldname }}" name="{{ f.fieldname }}" {% if f.reqd %}required{% endif %} />
|
|
{% else %}
|
|
<input
|
|
type="{% if f.options == 'Email' %}email{% elif f.options == 'Phone' or f.fieldtype == 'Phone' %}tel{% else %}text{% endif %}"
|
|
id="{{ f.fieldname }}" name="{{ f.fieldname }}" placeholder="{{ f.placeholder }}" {% if f.reqd %}required{% endif %} />
|
|
{% endif %}
|
|
{% endif %}
|
|
{% if f.description %}<div class="desc">{{ f.description }}</div>{% endif %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
|
|
<div class="actions">
|
|
<button type="reset" class="discard" id="discard-btn">{{ _("Discard") }}</button>
|
|
<button type="submit" class="submit" id="submit-btn">{{ submit_label }}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="success" id="success-view" style="display: none">
|
|
<div class="check-ring">✓</div>
|
|
<h2 id="success-title">{{ success_message }}</h2>
|
|
<p id="success-note">Your response has been recorded.</p>
|
|
<p class="redirect-note" id="redirect-note" style="display: none"></p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
var ROUTE = {{ form_route | tojson }};
|
|
var WEB_FORM = {{ web_form_name | tojson }};
|
|
var FIELDS = {{ fields | tojson }};
|
|
var CSRF = {{ csrf_token | tojson }};
|
|
var DRAFT = {{ draft_preview | tojson }};
|
|
var SUCCESS_URL = {{ success_url | tojson }};
|
|
var EMBED = {{ embed | tojson }};
|
|
var form = document.getElementById("web-form");
|
|
var errBox = document.getElementById("form-error");
|
|
var okBox = document.getElementById("form-ok");
|
|
var btn = document.getElementById("submit-btn");
|
|
var BREAKS = { "Section Break": 1, "Column Break": 1 };
|
|
|
|
// Field interdependencies: apply the DocType's depends_on / mandatory_depends_on
|
|
// / read_only_depends_on client-side, same rules as the Desk form.
|
|
function collectDoc() {
|
|
var doc = {};
|
|
FIELDS.forEach(function (f) {
|
|
if (BREAKS[f.fieldtype]) return;
|
|
var el = document.getElementById(f.fieldname);
|
|
if (!el) return;
|
|
doc[f.fieldname] = f.fieldtype === "Check" ? (el.checked ? 1 : 0) : el.value;
|
|
});
|
|
return doc;
|
|
}
|
|
// security boundary; tested by frontend/tests/unit/webFormConditionalEval.test.js
|
|
// [conditional-eval:start]
|
|
function coerce(v) {
|
|
return Array.isArray(v) ? v.length > 0 : !!v;
|
|
}
|
|
// Parse (never execute) an "eval:" expression, so stored metadata can't run JS on
|
|
// this public page. Supports doc/parent access, literals, comparisons, && || !,
|
|
// and parentheses; anything else throws → caller's fallback.
|
|
function safeEval(src, doc, parent) {
|
|
var i = 0;
|
|
function ws() {
|
|
while (i < src.length && /\s/.test(src[i])) i++;
|
|
}
|
|
function starts(tok) {
|
|
ws();
|
|
return src.substr(i, tok.length) === tok;
|
|
}
|
|
function primary() {
|
|
ws();
|
|
if (starts("(")) {
|
|
i++;
|
|
var e = orExpr();
|
|
ws();
|
|
if (!starts(")")) throw new Error("expected )");
|
|
i++;
|
|
return e;
|
|
}
|
|
if (starts("!")) {
|
|
i++;
|
|
return !coerce(primary());
|
|
}
|
|
var ch = src[i];
|
|
if (ch === '"' || ch === "'") {
|
|
i++;
|
|
var str = "";
|
|
while (i < src.length && src[i] !== ch) {
|
|
if (src[i] === "\\") i++;
|
|
str += src[i++];
|
|
}
|
|
i++;
|
|
return str;
|
|
}
|
|
var num = /^-?\d+(?:\.\d+)?/.exec(src.slice(i));
|
|
if (num) {
|
|
i += num[0].length;
|
|
return parseFloat(num[0]);
|
|
}
|
|
var id = /^[A-Za-z_$][\w$]*/.exec(src.slice(i));
|
|
if (!id) throw new Error("unexpected token");
|
|
i += id[0].length;
|
|
if (id[0] === "true") return true;
|
|
if (id[0] === "false") return false;
|
|
if (id[0] === "null") return null;
|
|
var base;
|
|
if (id[0] === "doc") base = doc;
|
|
else if (id[0] === "parent") base = parent;
|
|
else throw new Error("unknown identifier: " + id[0]); // no globals reachable
|
|
for (;;) {
|
|
ws();
|
|
if (src[i] === ".") {
|
|
i++;
|
|
var m = /^[A-Za-z_$][\w$]*/.exec(src.slice(i));
|
|
if (!m) throw new Error("bad member");
|
|
i += m[0].length;
|
|
base = base == null ? undefined : base[m[0]];
|
|
} else if (src[i] === "[") {
|
|
i++;
|
|
var key = orExpr();
|
|
ws();
|
|
if (!starts("]")) throw new Error("expected ]");
|
|
i++;
|
|
base = base == null ? undefined : base[key];
|
|
} else break;
|
|
}
|
|
return base;
|
|
}
|
|
function cmp() {
|
|
var l = primary();
|
|
ws();
|
|
var ops = ["===", "!==", "==", "!=", "<=", ">=", "<", ">"];
|
|
for (var k = 0; k < ops.length; k++) {
|
|
if (src.substr(i, ops[k].length) === ops[k]) {
|
|
i += ops[k].length;
|
|
var r = primary();
|
|
switch (ops[k]) {
|
|
case "===":
|
|
case "==":
|
|
return l == r; // eslint-disable-line eqeqeq
|
|
case "!==":
|
|
case "!=":
|
|
return l != r; // eslint-disable-line eqeqeq
|
|
case "<":
|
|
return l < r;
|
|
case "<=":
|
|
return l <= r;
|
|
case ">":
|
|
return l > r;
|
|
case ">=":
|
|
return l >= r;
|
|
}
|
|
}
|
|
}
|
|
return l;
|
|
}
|
|
function andExpr() {
|
|
var l = cmp();
|
|
while (starts("&&")) {
|
|
i += 2;
|
|
var r = cmp();
|
|
l = coerce(l) && coerce(r);
|
|
}
|
|
return l;
|
|
}
|
|
function orExpr() {
|
|
var l = andExpr();
|
|
while (starts("||")) {
|
|
i += 2;
|
|
var r = andExpr();
|
|
l = coerce(l) || coerce(r);
|
|
}
|
|
return l;
|
|
}
|
|
var out = orExpr();
|
|
ws();
|
|
if (i < src.length) throw new Error("trailing input");
|
|
return out;
|
|
}
|
|
// `fallback` covers an empty, unsupported, or invalid expression
|
|
function truthy(expr, doc, fallback) {
|
|
var s = expr ? String(expr).trim() : "";
|
|
if (!s) return fallback;
|
|
try {
|
|
if (s.slice(0, 5) === "eval:") return coerce(safeEval(s.slice(5), doc, doc));
|
|
if (s.slice(0, 3) === "fn:") return fallback;
|
|
return coerce(doc[s]);
|
|
} catch (e) {
|
|
return fallback;
|
|
}
|
|
}
|
|
// [conditional-eval:end]
|
|
function isVisible(f, doc) {
|
|
return truthy(f.depends_on, doc, true);
|
|
}
|
|
function isRequired(f, doc) {
|
|
return f.reqd ? true : truthy(f.mandatory_depends_on, doc, false);
|
|
}
|
|
function isReadOnly(f, doc) {
|
|
return truthy(f.read_only_depends_on, doc, false);
|
|
}
|
|
|
|
// apply the rules to the DOM: toggle visibility, read-only, and the required *
|
|
function refreshDeps() {
|
|
var doc = collectDoc();
|
|
FIELDS.forEach(function (f) {
|
|
if (BREAKS[f.fieldtype]) return;
|
|
var el = document.getElementById(f.fieldname);
|
|
if (!el) return;
|
|
var wrap = el.closest(".field");
|
|
var visible = isVisible(f, doc);
|
|
if (wrap) wrap.style.display = visible ? "" : "none";
|
|
|
|
var ro = isReadOnly(f, doc);
|
|
// selects/checkboxes have no readOnly; disable them (JS still reads .value)
|
|
if (f.fieldtype === "Check" || f.fieldtype === "Select" || f.fieldtype === "Link") {
|
|
el.disabled = ro;
|
|
} else {
|
|
el.readOnly = ro;
|
|
}
|
|
|
|
// toggle the * for a conditionally-mandatory field (static ones keep theirs)
|
|
if (wrap && !f.reqd) {
|
|
var star = wrap.querySelector(".req");
|
|
if (star) star.style.display = visible && isRequired(f, doc) ? "" : "none";
|
|
}
|
|
});
|
|
}
|
|
|
|
function showError(msg) {
|
|
errBox.textContent = msg || "Something went wrong. Please try again.";
|
|
errBox.style.display = "block";
|
|
errBox.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
}
|
|
// transient success acknowledgement used in embed mode (auto-hides)
|
|
function showOk(msg) {
|
|
okBox.textContent = msg || "Your response has been recorded.";
|
|
okBox.style.display = "block";
|
|
okBox.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
clearTimeout(showOk._t);
|
|
showOk._t = setTimeout(function () {
|
|
okBox.style.display = "none";
|
|
}, 5000);
|
|
}
|
|
function clearFieldErrors() {
|
|
[].forEach.call(form.querySelectorAll(".has-error"), function (el) {
|
|
el.classList.remove("has-error");
|
|
});
|
|
}
|
|
function redirectWithCountdown(url) {
|
|
var note = document.getElementById("redirect-note");
|
|
var link = document.createElement("a");
|
|
link.href = url;
|
|
link.textContent = "Go now";
|
|
var secs = 5;
|
|
function render() {
|
|
note.textContent =
|
|
"Redirecting in " + secs + (secs === 1 ? " second" : " seconds") + "…";
|
|
note.appendChild(link);
|
|
}
|
|
note.style.display = "block";
|
|
render();
|
|
var iv = setInterval(function () {
|
|
secs -= 1;
|
|
if (secs <= 0) {
|
|
clearInterval(iv);
|
|
window.location.assign(url);
|
|
return;
|
|
}
|
|
render();
|
|
}, 1000);
|
|
}
|
|
|
|
// keep the rules live as the user types/selects; also dismiss the success note
|
|
// as soon as they start the next entry
|
|
function onEdit() {
|
|
if (okBox.style.display !== "none") {
|
|
okBox.style.display = "none";
|
|
clearTimeout(showOk._t);
|
|
}
|
|
refreshDeps();
|
|
}
|
|
form.addEventListener("input", onEdit);
|
|
form.addEventListener("change", onEdit);
|
|
refreshDeps();
|
|
|
|
// Discard (type="reset") clears inputs; also drop errors and re-apply the rules
|
|
form.addEventListener("reset", function () {
|
|
errBox.style.display = "none";
|
|
clearFieldErrors();
|
|
setTimeout(refreshDeps, 0);
|
|
});
|
|
|
|
form.addEventListener("submit", function (e) {
|
|
e.preventDefault();
|
|
errBox.style.display = "none";
|
|
clearFieldErrors();
|
|
|
|
var doc = collectDoc();
|
|
var values = {};
|
|
var missing = [];
|
|
FIELDS.forEach(function (f) {
|
|
if (BREAKS[f.fieldtype]) return;
|
|
var el = document.getElementById(f.fieldname);
|
|
if (!el) return;
|
|
if (!isVisible(f, doc)) return; // hidden by depends_on → not submitted/validated
|
|
var val = f.fieldtype === "Check" ? (el.checked ? 1 : 0) : el.value;
|
|
values[f.fieldname] = val;
|
|
// a required checkbox must be checked; unchecked is 0, not "empty"
|
|
var empty =
|
|
f.fieldtype === "Check"
|
|
? val === 0
|
|
: val === "" || val === null || val === undefined;
|
|
// don't require a field the visitor can't edit (read-only) — otherwise a
|
|
// field that's both required and read-only would deadlock submission
|
|
if (isRequired(f, doc) && !isReadOnly(f, doc) && empty) {
|
|
missing.push(f);
|
|
el.classList.add("has-error");
|
|
}
|
|
});
|
|
|
|
if (missing.length) {
|
|
showError(
|
|
missing.length === 1
|
|
? "Please fill in the required field: " + (missing[0].label || "")
|
|
: "Please fill in all required fields."
|
|
);
|
|
var first = document.getElementById(missing[0].fieldname);
|
|
if (first && first.focus) first.focus();
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
var original = btn.textContent;
|
|
btn.textContent = "Submitting…";
|
|
|
|
// Draft preview → dry-run (validate only, no record). Published → submit
|
|
// through Frappe's built-in Web Form engine (validation + insert).
|
|
var endpoint = DRAFT
|
|
? "/api/method/crm.api.form.test_submit_form"
|
|
: "/api/method/frappe.website.doctype.web_form.web_form.accept";
|
|
var payload = DRAFT
|
|
? { name: WEB_FORM, values: JSON.stringify(values) }
|
|
: { web_form: WEB_FORM, data: JSON.stringify(values) };
|
|
|
|
fetch(endpoint, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", "X-Frappe-CSRF-Token": (CSRF || "") },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
.then(function (r) {
|
|
return r.json().then(function (data) { return { ok: r.ok, data: data }; });
|
|
})
|
|
.then(function (res) {
|
|
if (!res.ok) {
|
|
var m = res.data && res.data._server_messages;
|
|
var msg = "";
|
|
try { msg = m ? JSON.parse(JSON.parse(m)[0]).message : ""; } catch (e) {}
|
|
showError(msg || "Sorry, we couldn't submit your response. Please try again.");
|
|
btn.disabled = false;
|
|
btn.textContent = original;
|
|
return;
|
|
}
|
|
// embedded (and not redirecting): clear in place for the next submission
|
|
// instead of swapping the form out for the success page
|
|
if (EMBED && !DRAFT && !SUCCESS_URL) {
|
|
var okMsg = document.getElementById("success-title").textContent;
|
|
form.reset();
|
|
showOk(okMsg);
|
|
btn.disabled = false;
|
|
btn.textContent = original;
|
|
return;
|
|
}
|
|
document.getElementById("form-view").style.display = "none";
|
|
document.getElementById("success-view").style.display = "block";
|
|
if (DRAFT) {
|
|
document.getElementById("success-note").textContent =
|
|
"Test submission — no record was created. Publish the form to start collecting responses.";
|
|
} else if (SUCCESS_URL) {
|
|
// like the framework: show the success message, then redirect
|
|
// after a short countdown (with a manual fallback link)
|
|
redirectWithCountdown(SUCCESS_URL);
|
|
}
|
|
})
|
|
.catch(function () {
|
|
showError();
|
|
btn.disabled = false;
|
|
btn.textContent = original;
|
|
});
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|