ui: /f/[slug] two-column form|chat + phone-style bubbles + per-user color
m: "The difference between our form and the live chat needs to be more
prominent. I think we should style it more like a chat, too — maybe
separate into two columns — Form on the Left, Chat on the Right. And
chat messages more... like a chat on the phone. And we colorize different
users differently."
Layout
- New .fb-shell--wide modifier (max-width 960px) on the participant
shell so the two columns breathe. .fb-participant grid: 1fr at <900px,
1fr 1fr at ≥900px. Form column on the left, chat column on the right.
Single-column flow (form-only or chat-only) just fills its column.
- Title + description + closed-line + name field stay full-width above
the columns. Live results + footer stay full-width below.
Chat bubbles
- New .fb-bubble primitive replaces the old .fb-chat__post box-with-border
pattern on /f/[slug] only (admin moderator UI keeps .fb-chat__post).
- My posts: right-aligned, --color-primary background, white text, no
border. Others: left-aligned, --color-bg-secondary background, 3px
left-border in the speaker's deterministic color.
- Author name: tiny + weight 500. For others, name text is the speaker's
color; for me, it's --color-primary (you stay green).
- Timestamp: HH:mm if today, dd.MM HH:mm otherwise; viewer-localised.
- Vertical density: 0.6rem gap by default, +0.5rem extra when the speaker
changes (bumps to ~1.1rem — visually distinct turn boundaries).
Per-user color
- 7-color palette (rose / orange / amber / cyan / blue / violet / pink),
greens deliberately excluded so they don't clash with --color-primary
which the viewer's own bubbles use.
- Stable hash of client_session_id maps to a palette index — same user
gets the same color across reloads.
- Color applied to: author name text, bubble left-border. No avatar
circle in v1.
Compose
- New .fb-compose at the bottom of the chat column (in flex-flow, no
position:sticky needed — bubbles take flex:1 and scroll, compose stays
pinned). Textarea + send button side-by-side.
- Single-row textarea (rows=1, max-height 10rem) grows on input.
- Enter alone sends; Shift+Enter inserts a newline; IME composition is
respected (e.zh ne IME's confirm Enter doesn't submit).
- Placeholder includes the keyboard hint in German.
Auto-scroll
- $effect on posts.length scrolls the bubbles container to the bottom
on append + initial load with behavior:'smooth'. Replaces the old
imperative queueMicrotask calls inside fetchPosts/postChat.
Form column
- Section H2 ("Fragebogen") dropped — column boundary already names the
section. Submit button: full-width on mobile, auto-width on desktop.
- Submit-success banner now uses .fb-form-banner--success class
(replaces inline style hardcoded green).
CSS hygiene
- Removed orphaned .fb-chat__list / .fb-chat__form / .fb-chat__form-actions
/ .fb-chat__empty (only the participant page used them; new bubble
primitives replace them). Admin-side .fb-chat / .fb-chat__post / etc.
kept intact for the moderator UI.
Anti-scope honored: admin pages untouched, German strings unchanged,
3s polling interval kept, no new dependencies.
This commit is contained in:
@@ -523,22 +523,15 @@ body { min-height: 100vh; }
|
||||
|
||||
/* Chat */
|
||||
|
||||
/* Admin chat moderator UI — kept for /admin/feedback/[id] Chat tab.
|
||||
Participant page uses the new .fb-bubble* primitives below. */
|
||||
|
||||
.fb-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.fb-chat__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem;
|
||||
margin: -0.25rem;
|
||||
}
|
||||
|
||||
.fb-chat__post {
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -578,25 +571,6 @@ body { min-height: 100vh; }
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fb-chat__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fb-chat__form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.fb-chat__empty {
|
||||
text-align: center;
|
||||
color: var(--fb-muted);
|
||||
padding: 1.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.fb-foot {
|
||||
text-align: center;
|
||||
color: var(--fb-muted);
|
||||
@@ -1425,3 +1399,202 @@ body { min-height: 100vh; }
|
||||
flex-wrap: wrap;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────────
|
||||
Participant page — two-column form|chat layout, phone-style
|
||||
bubbles, sticky compose. Used only on /f/[slug].
|
||||
───────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* Wider shell on participant page so the two columns breathe. */
|
||||
.fb-shell.fb-shell--wide {
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.fb-participant {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-7);
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.fb-participant {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.fb-participant__col {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Form column — sequential questions, submit at the bottom. */
|
||||
.fb-participant__col--form {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.fb-participant__col--form .fb-question {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.fb-participant__submit {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.fb-participant__submit {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.fb-participant__submit-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat column — bubbles fill the column height, compose pinned at bottom. */
|
||||
.fb-participant__col--chat {
|
||||
background: var(--color-bg-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-3);
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 899px) {
|
||||
.fb-participant__col--chat {
|
||||
max-height: 70vh;
|
||||
}
|
||||
}
|
||||
|
||||
.fb-bubbles {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem;
|
||||
margin: -0.25rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.fb-bubbles__empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-5) 0;
|
||||
font-size: 0.9rem;
|
||||
margin: auto 0;
|
||||
}
|
||||
|
||||
/* Bubble — defaults to left-aligned (other speakers); --mine flips to right. */
|
||||
.fb-bubble {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
max-width: 80%;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.fb-bubble--speaker-change {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.fb-bubble--mine {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.fb-bubble__author {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.fb-bubble--mine .fb-bubble__author {
|
||||
align-self: flex-end;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.fb-bubble__body {
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.45;
|
||||
font-size: 0.95rem;
|
||||
border-left: 3px solid var(--color-border-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.fb-bubble--mine .fb-bubble__body {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-text-inverse);
|
||||
border-left: 0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.fb-bubble__body--hidden {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
border-left-color: var(--color-border-primary);
|
||||
}
|
||||
|
||||
.fb-bubble--mine .fb-bubble__body--hidden {
|
||||
background: var(--color-bg-tertiary);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.fb-bubble__time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.fb-bubble--mine .fb-bubble__time {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
/* Compose — pinned to the bottom of the chat column via flex layout. */
|
||||
.fb-compose {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
padding-top: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.fb-compose__textarea {
|
||||
flex: 1;
|
||||
min-height: 2.5rem;
|
||||
max-height: 10rem;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.fb-compose__send {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fb-compose__error {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Submit-success / already-submitted banners on the form column —
|
||||
replaces the old inline style hardcoded green. */
|
||||
.fb-form-banner--success {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary-hover);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.fb-form-banner--success {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,25 @@
|
||||
let results = $state<AggregatedResults | null>(null);
|
||||
let resultsPollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Per-user color palette — deterministic hash of client_session_id maps to
|
||||
// one of these colors. Greens excluded so they don't clash with --color-primary
|
||||
// (which is reserved for the viewer's own bubbles).
|
||||
const PARTICIPANT_PALETTE = [
|
||||
'#e11d48', // rose
|
||||
'#ea580c', // orange
|
||||
'#ca8a04', // amber
|
||||
'#0891b2', // cyan
|
||||
'#2563eb', // blue
|
||||
'#7c3aed', // violet
|
||||
'#db2777', // pink
|
||||
];
|
||||
|
||||
function colorForSession(sid: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < sid.length; i++) h = ((h << 5) - h + sid.charCodeAt(i)) | 0;
|
||||
return PARTICIPANT_PALETTE[Math.abs(h) % PARTICIPANT_PALETTE.length];
|
||||
}
|
||||
|
||||
function uuid(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return crypto.randomUUID();
|
||||
@@ -108,9 +127,6 @@
|
||||
if (fresh.length) posts = [...posts, ...fresh];
|
||||
}
|
||||
lastSeenAt = body.posts[body.posts.length - 1].created_at;
|
||||
queueMicrotask(() => {
|
||||
if (chatListEl) chatListEl.scrollTop = chatListEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// network blip — next poll retries
|
||||
@@ -154,9 +170,6 @@
|
||||
}
|
||||
chatBody = '';
|
||||
saveName();
|
||||
queueMicrotask(() => {
|
||||
if (chatListEl) chatListEl.scrollTop = chatListEl.scrollHeight;
|
||||
});
|
||||
} catch (e) {
|
||||
chatError = e instanceof Error ? e.message : 'Netzwerkfehler';
|
||||
} finally {
|
||||
@@ -267,9 +280,26 @@
|
||||
return p.client_session_id === sessionId;
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
// Today: HH:mm — earlier days: dd.MM HH:mm. Localised to viewer.
|
||||
const sameDayFmt = new Intl.DateTimeFormat([], { hour: '2-digit', minute: '2-digit' });
|
||||
const dayBeforeFmt = new Intl.DateTimeFormat([], {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
function fmtBubbleTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const now = new Date();
|
||||
if (
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate()
|
||||
) {
|
||||
return sameDayFmt.format(d);
|
||||
}
|
||||
return dayBeforeFmt.format(d);
|
||||
}
|
||||
|
||||
function toggleMultiChoice(q: FeedbackQuestion, opt: string): void {
|
||||
@@ -322,6 +352,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function onComposeKeydown(e: KeyboardEvent): void {
|
||||
// Enter alone (no shift, no IME composition) sends. Shift+Enter inserts a newline.
|
||||
// Cmd/Ctrl+Enter also sends — covers users who expect that shortcut.
|
||||
if (e.key !== 'Enter' || e.isComposing) return;
|
||||
if (e.shiftKey) return;
|
||||
e.preventDefault();
|
||||
void postChat();
|
||||
}
|
||||
|
||||
// Auto-scroll the chat column to the bottom on initial load and when a new
|
||||
// post arrives. Reading posts.length keeps this effect reactive to appends.
|
||||
$effect(() => {
|
||||
const _len = posts.length;
|
||||
if (_len === 0 || !chatListEl) return;
|
||||
queueMicrotask(() => {
|
||||
if (chatListEl) {
|
||||
chatListEl.scrollTo({ top: chatListEl.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
displayName = loadName();
|
||||
const s = loadSession();
|
||||
@@ -348,7 +399,7 @@
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="fb-shell">
|
||||
<div class="fb-shell fb-shell--wide">
|
||||
<header class="fb-header">
|
||||
<h1>{data.title}</h1>
|
||||
{#if data.description}
|
||||
@@ -377,255 +428,257 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if chatEnabled}
|
||||
<section class="fb-section" aria-label="Live-Chat">
|
||||
<h2>Live-Feedback</h2>
|
||||
<div class="fb-chat">
|
||||
<div class="fb-chat__list" bind:this={chatListEl}>
|
||||
{#if posts.length === 0}
|
||||
<div class="fb-chat__empty">Noch keine Beiträge — sei der erste.</div>
|
||||
{/if}
|
||||
{#each posts as p (p.id)}
|
||||
<div
|
||||
class="fb-chat__post {isMine(p) ? 'fb-chat__post--mine' : ''} {p.hidden && !isMine(p)
|
||||
? 'fb-chat__post--hidden'
|
||||
: ''}"
|
||||
>
|
||||
<div class="fb-chat__meta">
|
||||
<span class="fb-chat__name">{p.display_name ?? 'anonym'}</span>
|
||||
<span>{fmtTime(p.created_at)}</span>
|
||||
<div class="fb-participant">
|
||||
{#if formDef}
|
||||
<section class="fb-participant__col fb-participant__col--form" aria-label="Formular">
|
||||
{#if submitSuccess}
|
||||
<div class="fb-banner fb-form-banner--success">
|
||||
Danke für dein Feedback!
|
||||
</div>
|
||||
<button type="button" class="fb-btn fb-btn--ghost" onclick={resetSubmissionUi}>
|
||||
Noch eine Antwort senden
|
||||
</button>
|
||||
{:else if alreadySubmitted}
|
||||
<div class="fb-banner">
|
||||
Du hast schon abgesendet. Du kannst trotzdem nochmal antworten:
|
||||
</div>
|
||||
<button type="button" class="fb-btn fb-btn--ghost" onclick={resetSubmissionUi}>
|
||||
Erneut antworten
|
||||
</button>
|
||||
{:else if isClosed}
|
||||
<div class="fb-banner">Das Formular ist geschlossen.</div>
|
||||
{:else}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitForm();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="company"
|
||||
class="fb-honeypot"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{#each formDef.questions as q (q.id)}
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for={`q-${q.id}`}>
|
||||
{q.label}{#if q.required}<span class="fb-question__required">*</span>{/if}
|
||||
</label>
|
||||
|
||||
{#if q.type === 'short_text'}
|
||||
<input
|
||||
id={`q-${q.id}`}
|
||||
type="text"
|
||||
class="fb-input"
|
||||
placeholder={q.placeholder ?? ''}
|
||||
maxlength="500"
|
||||
value={(answers[q.id] as string) ?? ''}
|
||||
oninput={(e) => setAnswer(q.id, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
{:else if q.type === 'long_text'}
|
||||
<textarea
|
||||
id={`q-${q.id}`}
|
||||
class="fb-textarea"
|
||||
placeholder={q.placeholder ?? ''}
|
||||
maxlength="5000"
|
||||
rows="4"
|
||||
value={(answers[q.id] as string) ?? ''}
|
||||
oninput={(e) => setAnswer(q.id, (e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
{:else if q.type === 'single_choice'}
|
||||
<div class="fb-options">
|
||||
{#each q.options as opt (opt)}
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${q.id}`}
|
||||
checked={answers[q.id] === opt}
|
||||
onchange={() => setAnswer(q.id, opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if q.type === 'multi_choice'}
|
||||
<div class="fb-options">
|
||||
{#each q.options as opt (opt)}
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Array.isArray(answers[q.id]) &&
|
||||
(answers[q.id] as string[]).includes(opt)}
|
||||
onchange={() => toggleMultiChoice(q, opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if q.type === 'scale'}
|
||||
<div class="fb-scale">
|
||||
{#each Array.from({ length: q.max - q.min + 1 }, (_, i) => i + q.min) as v (v)}
|
||||
<button
|
||||
type="button"
|
||||
class="fb-scale__btn {answers[q.id] === v ? 'fb-scale__btn--active' : ''}"
|
||||
onclick={() => setAnswer(q.id, v)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if q.min_label || q.max_label}
|
||||
<div class="fb-scale__labels">
|
||||
<span>{q.min_label ?? q.min}</span>
|
||||
<span>{q.max_label ?? q.max}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if q.type === 'boolean'}
|
||||
<div class="fb-options">
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${q.id}`}
|
||||
checked={answers[q.id] === true}
|
||||
onchange={() => setAnswer(q.id, true)}
|
||||
/>
|
||||
<span>Ja</span>
|
||||
</label>
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${q.id}`}
|
||||
checked={answers[q.id] === false}
|
||||
onchange={() => setAnswer(q.id, false)}
|
||||
/>
|
||||
<span>Nein</span>
|
||||
</label>
|
||||
</div>
|
||||
{:else if q.type === 'date_ranked_choice'}
|
||||
<div class="fb-date-ranked">
|
||||
{#if q.scale?.min_label || q.scale?.max_label}
|
||||
<div class="fb-scale__labels" style="margin-bottom: 0.5rem;">
|
||||
<span>1 — {q.scale.min_label ?? 'passt nicht'}</span>
|
||||
<span>5 — {q.scale.max_label ?? 'passt super'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each q.options as opt (opt.id)}
|
||||
<div class="fb-date-ranked__row">
|
||||
<div class="fb-date-ranked__opt">
|
||||
<div class="fb-date-ranked__when">{fmtDateOption(opt.start, opt.end)}</div>
|
||||
{#if opt.label}<div class="fb-date-ranked__label">{opt.label}</div>{/if}
|
||||
</div>
|
||||
<div class="fb-scale fb-date-ranked__scale" role="radiogroup" aria-label={opt.label ?? fmtDateOption(opt.start, opt.end)}>
|
||||
{#each [1, 2, 3, 4, 5] as v (v)}
|
||||
<button
|
||||
type="button"
|
||||
class="fb-scale__btn {dateRankedRating(q.id, opt.id) === v ? 'fb-scale__btn--active' : ''}"
|
||||
aria-pressed={dateRankedRating(q.id, opt.id) === v}
|
||||
onclick={() => setDateRankedRating(q.id, opt.id, v)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="fb-date-ranked__skip {dateRankedRating(q.id, opt.id) === null ? 'fb-date-ranked__skip--active' : ''}"
|
||||
onclick={() => setDateRankedRating(q.id, opt.id, null)}
|
||||
aria-label="Skip option"
|
||||
>
|
||||
—
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if q.help}
|
||||
<div class="fb-question__help">{q.help}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fb-chat__body">
|
||||
{#if p.hidden && !isMine(p)}
|
||||
{/each}
|
||||
|
||||
{#if submitError}
|
||||
<div class="fb-banner fb-banner--error">{submitError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="fb-participant__submit">
|
||||
<button type="submit" class="fb-btn fb-participant__submit-btn" disabled={submitInFlight}>
|
||||
{submitInFlight ? 'Sende …' : 'Absenden'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if chatEnabled}
|
||||
<section class="fb-participant__col fb-participant__col--chat" aria-label="Live-Chat">
|
||||
<div class="fb-bubbles" bind:this={chatListEl}>
|
||||
{#if posts.length === 0}
|
||||
<div class="fb-bubbles__empty">Noch keine Beiträge — sei der erste.</div>
|
||||
{/if}
|
||||
{#each posts as p, i (p.id)}
|
||||
{@const mine = isMine(p)}
|
||||
{@const speakerChange = i > 0 && posts[i - 1].client_session_id !== p.client_session_id}
|
||||
{@const userColor = mine ? 'var(--color-primary)' : colorForSession(p.client_session_id)}
|
||||
<div
|
||||
class="fb-bubble {mine ? 'fb-bubble--mine' : ''} {speakerChange ? 'fb-bubble--speaker-change' : ''}"
|
||||
>
|
||||
<div class="fb-bubble__author" style={mine ? '' : `color: ${userColor};`}>
|
||||
{p.display_name ?? 'anonym'}
|
||||
</div>
|
||||
<div
|
||||
class="fb-bubble__body {p.hidden ? 'fb-bubble__body--hidden' : ''}"
|
||||
style={mine ? '' : `border-left-color: ${userColor};`}
|
||||
>
|
||||
{#if p.hidden && !mine}
|
||||
<em>(Beitrag entfernt)</em>
|
||||
{:else if p.hidden && isMine(p)}
|
||||
{:else if p.hidden && mine}
|
||||
<em>(von Moderation entfernt — du siehst es noch)</em><br />{p.body}
|
||||
{:else}
|
||||
{p.body}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fb-bubble__time">{fmtBubbleTime(p.created_at)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if chatStatus === 'open'}
|
||||
{#if chatError}
|
||||
<div class="fb-compose__error" role="alert">{chatError}</div>
|
||||
{/if}
|
||||
<form
|
||||
class="fb-chat__form"
|
||||
class="fb-compose"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
postChat();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
class="fb-textarea"
|
||||
placeholder="Dein Beitrag …"
|
||||
class="fb-textarea fb-compose__textarea"
|
||||
placeholder="Dein Beitrag … (Enter zum Senden, Shift+Enter für Zeilenumbruch)"
|
||||
maxlength="2000"
|
||||
bind:value={chatBody}
|
||||
rows="2"
|
||||
onkeydown={onComposeKeydown}
|
||||
rows="1"
|
||||
></textarea>
|
||||
{#if chatError}
|
||||
<div class="fb-banner fb-banner--error">{chatError}</div>
|
||||
{/if}
|
||||
<div class="fb-chat__form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
class="fb-btn"
|
||||
disabled={chatPostInFlight || chatBody.trim().length === 0}
|
||||
>
|
||||
{chatPostInFlight ? 'Sende …' : 'Senden'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="fb-btn fb-compose__send"
|
||||
disabled={chatPostInFlight || chatBody.trim().length === 0}
|
||||
>
|
||||
{chatPostInFlight ? 'Sende …' : 'Senden'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if formDef}
|
||||
<section class="fb-section" aria-label="Formular">
|
||||
<h2>Fragebogen</h2>
|
||||
|
||||
{#if submitSuccess}
|
||||
<div class="fb-banner" style="background: #dcfce7; color: #14532d; border-color: #bbf7d0;">
|
||||
Danke für dein Feedback!
|
||||
</div>
|
||||
<button type="button" class="fb-btn fb-btn--ghost" onclick={resetSubmissionUi}>
|
||||
Noch eine Antwort senden
|
||||
</button>
|
||||
{:else if alreadySubmitted}
|
||||
<div class="fb-banner">
|
||||
Du hast schon abgesendet. Du kannst trotzdem nochmal antworten:
|
||||
</div>
|
||||
<button type="button" class="fb-btn fb-btn--ghost" onclick={resetSubmissionUi}>
|
||||
Erneut antworten
|
||||
</button>
|
||||
{:else if isClosed}
|
||||
<div class="fb-banner">Das Formular ist geschlossen.</div>
|
||||
{:else}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitForm();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="company"
|
||||
class="fb-honeypot"
|
||||
tabindex="-1"
|
||||
autocomplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{#each formDef.questions as q (q.id)}
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for={`q-${q.id}`}>
|
||||
{q.label}{#if q.required}<span class="fb-question__required">*</span>{/if}
|
||||
</label>
|
||||
|
||||
{#if q.type === 'short_text'}
|
||||
<input
|
||||
id={`q-${q.id}`}
|
||||
type="text"
|
||||
class="fb-input"
|
||||
placeholder={q.placeholder ?? ''}
|
||||
maxlength="500"
|
||||
value={(answers[q.id] as string) ?? ''}
|
||||
oninput={(e) => setAnswer(q.id, (e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
{:else if q.type === 'long_text'}
|
||||
<textarea
|
||||
id={`q-${q.id}`}
|
||||
class="fb-textarea"
|
||||
placeholder={q.placeholder ?? ''}
|
||||
maxlength="5000"
|
||||
rows="4"
|
||||
value={(answers[q.id] as string) ?? ''}
|
||||
oninput={(e) => setAnswer(q.id, (e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
{:else if q.type === 'single_choice'}
|
||||
<div class="fb-options">
|
||||
{#each q.options as opt (opt)}
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${q.id}`}
|
||||
checked={answers[q.id] === opt}
|
||||
onchange={() => setAnswer(q.id, opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if q.type === 'multi_choice'}
|
||||
<div class="fb-options">
|
||||
{#each q.options as opt (opt)}
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Array.isArray(answers[q.id]) &&
|
||||
(answers[q.id] as string[]).includes(opt)}
|
||||
onchange={() => toggleMultiChoice(q, opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if q.type === 'scale'}
|
||||
<div class="fb-scale">
|
||||
{#each Array.from({ length: q.max - q.min + 1 }, (_, i) => i + q.min) as v (v)}
|
||||
<button
|
||||
type="button"
|
||||
class="fb-scale__btn {answers[q.id] === v ? 'fb-scale__btn--active' : ''}"
|
||||
onclick={() => setAnswer(q.id, v)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if q.min_label || q.max_label}
|
||||
<div class="fb-scale__labels">
|
||||
<span>{q.min_label ?? q.min}</span>
|
||||
<span>{q.max_label ?? q.max}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if q.type === 'boolean'}
|
||||
<div class="fb-options">
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${q.id}`}
|
||||
checked={answers[q.id] === true}
|
||||
onchange={() => setAnswer(q.id, true)}
|
||||
/>
|
||||
<span>Ja</span>
|
||||
</label>
|
||||
<label class="fb-option-row">
|
||||
<input
|
||||
type="radio"
|
||||
name={`q-${q.id}`}
|
||||
checked={answers[q.id] === false}
|
||||
onchange={() => setAnswer(q.id, false)}
|
||||
/>
|
||||
<span>Nein</span>
|
||||
</label>
|
||||
</div>
|
||||
{:else if q.type === 'date_ranked_choice'}
|
||||
<div class="fb-date-ranked">
|
||||
{#if q.scale?.min_label || q.scale?.max_label}
|
||||
<div class="fb-scale__labels" style="margin-bottom: 0.5rem;">
|
||||
<span>1 — {q.scale.min_label ?? 'passt nicht'}</span>
|
||||
<span>5 — {q.scale.max_label ?? 'passt super'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each q.options as opt (opt.id)}
|
||||
<div class="fb-date-ranked__row">
|
||||
<div class="fb-date-ranked__opt">
|
||||
<div class="fb-date-ranked__when">{fmtDateOption(opt.start, opt.end)}</div>
|
||||
{#if opt.label}<div class="fb-date-ranked__label">{opt.label}</div>{/if}
|
||||
</div>
|
||||
<div class="fb-scale fb-date-ranked__scale" role="radiogroup" aria-label={opt.label ?? fmtDateOption(opt.start, opt.end)}>
|
||||
{#each [1, 2, 3, 4, 5] as v (v)}
|
||||
<button
|
||||
type="button"
|
||||
class="fb-scale__btn {dateRankedRating(q.id, opt.id) === v ? 'fb-scale__btn--active' : ''}"
|
||||
aria-pressed={dateRankedRating(q.id, opt.id) === v}
|
||||
onclick={() => setDateRankedRating(q.id, opt.id, v)}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
type="button"
|
||||
class="fb-date-ranked__skip {dateRankedRating(q.id, opt.id) === null ? 'fb-date-ranked__skip--active' : ''}"
|
||||
onclick={() => setDateRankedRating(q.id, opt.id, null)}
|
||||
aria-label="Skip option"
|
||||
>
|
||||
—
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if q.help}
|
||||
<div class="fb-question__help">{q.help}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if submitError}
|
||||
<div class="fb-banner fb-banner--error">{submitError}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="fb-btn" disabled={submitInFlight}>
|
||||
{submitInFlight ? 'Sende …' : 'Absenden'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if liveResultsEnabled && (alreadySubmitted || submitSuccess) && results}
|
||||
<section class="fb-section" aria-label="Live-Ergebnisse">
|
||||
|
||||
Reference in New Issue
Block a user