@commonpub/layer 0.83.1 → 0.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/contest/ContestAdvancementPanel.vue +138 -0
- package/components/contest/ContestBodyCanvas.vue +23 -14
- package/components/contest/ContestEditor.vue +61 -128
- package/components/contest/ContestStageCard.vue +200 -0
- package/components/contest/ContestStageTemplateEditor.vue +191 -0
- package/components/contest/ContestStagesEditor.vue +25 -325
- package/composables/useContestEditor.ts +26 -1
- package/package.json +6 -6
- package/utils/contestStages.ts +80 -51
- package/utils/contestTemplates.ts +116 -0
- package/server/api/content/[id]/__tests__/versions.get.test.ts +0 -127
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContestStageCard — one stage row in the ContestStagesEditor list, extracted so
|
|
4
|
+
* the orchestrator stays a thin list + toolbar. Presentational + intent-emitting:
|
|
5
|
+
* it renders one stage's fields and emits granular changes (`patch`, `move`,
|
|
6
|
+
* `duplicate`, `remove`, `set-current`); the parent applies them with the pure
|
|
7
|
+
* array ops in utils/contestStages.ts. Flag-gated extras (submission mode, the
|
|
8
|
+
* submission-form builder) re-derive `useFeatures()` here rather than prop-drilling.
|
|
9
|
+
*/
|
|
10
|
+
import type { ContestStage } from '@commonpub/schema';
|
|
11
|
+
import ContestStageTemplateEditor from './ContestStageTemplateEditor.vue';
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
stage: ContestStage;
|
|
15
|
+
index: number;
|
|
16
|
+
isCurrent: boolean;
|
|
17
|
+
isFirst: boolean;
|
|
18
|
+
isLast: boolean;
|
|
19
|
+
}>();
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
patch: [patch: Partial<ContestStage>];
|
|
23
|
+
move: [dir: -1 | 1];
|
|
24
|
+
duplicate: [];
|
|
25
|
+
remove: [];
|
|
26
|
+
'set-current': [];
|
|
27
|
+
}>();
|
|
28
|
+
|
|
29
|
+
const KINDS: ContestStage['kind'][] = ['submission', 'review', 'interim', 'results', 'event', 'custom'];
|
|
30
|
+
|
|
31
|
+
const { features } = useFeatures();
|
|
32
|
+
const templatesEnabled = computed(() => features.value.contestStageSubmissions !== false);
|
|
33
|
+
const proposalsEnabled = computed(() => features.value.contestProposals === true);
|
|
34
|
+
|
|
35
|
+
function setField(patch: Partial<ContestStage>): void {
|
|
36
|
+
emit('patch', patch);
|
|
37
|
+
}
|
|
38
|
+
function advanceCountInput(e: Event): void {
|
|
39
|
+
const v = (e.target as HTMLInputElement).value;
|
|
40
|
+
setField({ advanceCount: v === '' ? undefined : Math.max(1, Math.round(Number(v))) });
|
|
41
|
+
}
|
|
42
|
+
// The template editor hands back the whole field array; mirror the stored shape
|
|
43
|
+
// (empty → undefined) so a cleared form drops the key entirely.
|
|
44
|
+
function onTemplateUpdate(template: ContestStage['submissionTemplate']): void {
|
|
45
|
+
setField({ submissionTemplate: template && template.length ? template : undefined });
|
|
46
|
+
}
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<li class="cpub-stage-row">
|
|
51
|
+
<div class="cpub-stage-row-head">
|
|
52
|
+
<span class="cpub-stage-num">{{ index + 1 }}</span>
|
|
53
|
+
<label class="cpub-stage-current" :title="isCurrent ? 'This is the current stage' : 'Mark as the current stage'">
|
|
54
|
+
<input
|
|
55
|
+
type="radio"
|
|
56
|
+
name="cpub-current-stage"
|
|
57
|
+
:checked="isCurrent"
|
|
58
|
+
@change="emit('set-current')"
|
|
59
|
+
/>
|
|
60
|
+
<span>Current</span>
|
|
61
|
+
</label>
|
|
62
|
+
<div class="cpub-stage-row-actions">
|
|
63
|
+
<button type="button" class="cpub-stage-iconbtn" :disabled="isFirst" aria-label="Move up" @click="emit('move', -1)"><i class="fa-solid fa-arrow-up"></i></button>
|
|
64
|
+
<button type="button" class="cpub-stage-iconbtn" :disabled="isLast" aria-label="Move down" @click="emit('move', 1)"><i class="fa-solid fa-arrow-down"></i></button>
|
|
65
|
+
<button type="button" class="cpub-stage-iconbtn" aria-label="Duplicate stage" @click="emit('duplicate')"><i class="fa-solid fa-clone"></i></button>
|
|
66
|
+
<button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove stage" @click="emit('remove')"><i class="fa-solid fa-xmark"></i></button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div class="cpub-form-row">
|
|
71
|
+
<div class="cpub-form-field" style="flex: 2;">
|
|
72
|
+
<label :for="`stage-name-${index}`" class="cpub-form-label">Stage name</label>
|
|
73
|
+
<input
|
|
74
|
+
:id="`stage-name-${index}`"
|
|
75
|
+
:value="stage.name"
|
|
76
|
+
type="text"
|
|
77
|
+
class="cpub-form-input"
|
|
78
|
+
placeholder="e.g. Proposals Open"
|
|
79
|
+
@input="setField({ name: ($event.target as HTMLInputElement).value })"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="cpub-form-field" style="flex: 1;">
|
|
83
|
+
<label :for="`stage-type-${index}`" class="cpub-form-label">Type</label>
|
|
84
|
+
<select
|
|
85
|
+
:id="`stage-type-${index}`"
|
|
86
|
+
:value="stage.kind"
|
|
87
|
+
class="cpub-form-input"
|
|
88
|
+
@change="setField({ kind: ($event.target as HTMLSelectElement).value as ContestStage['kind'] })"
|
|
89
|
+
>
|
|
90
|
+
<option v-for="k in KINDS" :key="k" :value="k">{{ STAGE_KIND_LABEL[k] }}</option>
|
|
91
|
+
</select>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<p class="cpub-stage-kind-help"><i class="fa-solid fa-circle-info"></i> {{ STAGE_KIND_HELP[stage.kind] }}</p>
|
|
96
|
+
|
|
97
|
+
<div class="cpub-form-row">
|
|
98
|
+
<CpubDateTimeField
|
|
99
|
+
label="Starts"
|
|
100
|
+
:model-value="stage.startsAt"
|
|
101
|
+
:max="stage.endsAt"
|
|
102
|
+
@update:model-value="setField({ startsAt: $event })"
|
|
103
|
+
/>
|
|
104
|
+
<CpubDateTimeField
|
|
105
|
+
label="Ends (countdown target)"
|
|
106
|
+
:model-value="stage.endsAt"
|
|
107
|
+
:min="stage.startsAt"
|
|
108
|
+
@update:model-value="setField({ endsAt: $event })"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div class="cpub-form-field">
|
|
113
|
+
<label :for="`stage-desc-${index}`" class="cpub-form-label">Description (optional)</label>
|
|
114
|
+
<input
|
|
115
|
+
:id="`stage-desc-${index}`"
|
|
116
|
+
:value="stage.description ?? ''"
|
|
117
|
+
type="text"
|
|
118
|
+
class="cpub-form-input"
|
|
119
|
+
placeholder="What happens, or what to submit/refine, this stage"
|
|
120
|
+
@input="setField({ description: ($event.target as HTMLInputElement).value || undefined })"
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
|
|
124
|
+
<!-- Per-round config (review stages): how many advance + the rubric -->
|
|
125
|
+
<div v-if="stage.kind === 'review'" class="cpub-stage-criteria">
|
|
126
|
+
<div class="cpub-form-field" style="margin-bottom: 10px;">
|
|
127
|
+
<label :for="`stage-advance-${index}`" class="cpub-form-label">Advance the top N to the next stage</label>
|
|
128
|
+
<input :id="`stage-advance-${index}`" :value="stage.advanceCount ?? ''" type="number" min="1" class="cpub-form-input cpub-stage-advn" placeholder="e.g. 50, leave blank to decide at advance time" @input="advanceCountInput($event)" />
|
|
129
|
+
</div>
|
|
130
|
+
<p class="cpub-form-hint" style="margin: 4px 0;">Optional, leave empty to use the contest’s default criteria. Set per-round criteria for multi-round contests (e.g. judge proposals on Feasibility, prototypes on Deployment readiness).</p>
|
|
131
|
+
<ContestCriteriaEditor
|
|
132
|
+
:model-value="(stage.criteria ?? [])"
|
|
133
|
+
label="Judging criteria, this round"
|
|
134
|
+
:show-total="false"
|
|
135
|
+
@update:model-value="setField({ criteria: ($event as ContestStage['criteria']) })"
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<!-- Submission mode (Phase 4): attach an existing project, or collect a
|
|
140
|
+
form-first proposal that seeds a draft placeholder project. -->
|
|
141
|
+
<div v-if="stage.kind === 'submission' && proposalsEnabled" class="cpub-form-field">
|
|
142
|
+
<label :for="`stage-mode-${index}`" class="cpub-form-label">How entrants submit</label>
|
|
143
|
+
<select
|
|
144
|
+
:id="`stage-mode-${index}`"
|
|
145
|
+
:value="stage.submissionMode ?? 'attach'"
|
|
146
|
+
class="cpub-form-input"
|
|
147
|
+
@change="setField({ submissionMode: (($event.target as HTMLSelectElement).value as 'attach' | 'proposal') })"
|
|
148
|
+
>
|
|
149
|
+
<option value="attach">Attach an existing published project</option>
|
|
150
|
+
<option value="proposal">Proposal form (creates a draft project)</option>
|
|
151
|
+
</select>
|
|
152
|
+
<p class="cpub-form-hint" style="margin: 4px 0;">Proposal mode lets entrants apply with just this form. The server creates a draft project they develop for later rounds.</p>
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
<!-- Per-stage submission template (submission stages): the artifact fields
|
|
156
|
+
entrants fill for THIS stage (proposal vs prototype). -->
|
|
157
|
+
<ContestStageTemplateEditor
|
|
158
|
+
v-if="stage.kind === 'submission' && templatesEnabled"
|
|
159
|
+
:template="stage.submissionTemplate ?? []"
|
|
160
|
+
@update:template="onTemplateUpdate"
|
|
161
|
+
/>
|
|
162
|
+
|
|
163
|
+
<div v-if="stage.kind === 'event'" class="cpub-form-row">
|
|
164
|
+
<div class="cpub-form-field">
|
|
165
|
+
<label :for="`stage-location-${index}`" class="cpub-form-label">Location</label>
|
|
166
|
+
<input :id="`stage-location-${index}`" :value="stage.location ?? ''" type="text" class="cpub-form-input" placeholder="e.g. Washington, D.C." @input="setField({ location: ($event.target as HTMLInputElement).value || undefined })" />
|
|
167
|
+
</div>
|
|
168
|
+
<div class="cpub-form-field">
|
|
169
|
+
<label :for="`stage-url-${index}`" class="cpub-form-label">Link</label>
|
|
170
|
+
<input :id="`stage-url-${index}`" :value="stage.url ?? ''" type="url" class="cpub-form-input" placeholder="https://…" @input="setField({ url: ($event.target as HTMLInputElement).value || undefined })" />
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</li>
|
|
174
|
+
</template>
|
|
175
|
+
|
|
176
|
+
<style scoped>
|
|
177
|
+
/* Form controls + stage-row styles travel with this extracted markup (scoped CSS
|
|
178
|
+
doesn't cross component boundaries; the global theme only ships
|
|
179
|
+
.cpub-form-label/.cpub-form-hint/.cpub-btn). */
|
|
180
|
+
.cpub-form-field { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-3); }
|
|
181
|
+
.cpub-form-field:last-child { margin-bottom: 0; }
|
|
182
|
+
.cpub-form-input { width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans); }
|
|
183
|
+
.cpub-form-input:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
|
|
184
|
+
.cpub-form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--space-3); }
|
|
185
|
+
|
|
186
|
+
.cpub-stage-row { border: var(--border-width-default) solid var(--border); background: var(--surface2); padding: 12px; }
|
|
187
|
+
.cpub-stage-row-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
|
188
|
+
.cpub-stage-num { width: 22px; height: 22px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700; font-family: var(--font-mono); background: var(--accent-bg); color: var(--accent); border: var(--border-width-default) solid var(--accent-border); }
|
|
189
|
+
.cpub-stage-current { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); cursor: pointer; }
|
|
190
|
+
.cpub-stage-current input { width: 13px; height: 13px; }
|
|
191
|
+
.cpub-stage-row-actions { margin-left: auto; display: flex; gap: 4px; }
|
|
192
|
+
.cpub-stage-iconbtn { background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text-dim); cursor: pointer; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; }
|
|
193
|
+
.cpub-stage-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
194
|
+
.cpub-stage-iconbtn:disabled { opacity: .4; cursor: not-allowed; }
|
|
195
|
+
.cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
|
|
196
|
+
.cpub-stage-kind-help { font-size: 11px; color: var(--text-faint); line-height: 1.5; margin: 0 0 4px; display: flex; gap: 6px; }
|
|
197
|
+
.cpub-stage-kind-help i { color: var(--accent); margin-top: 2px; flex-shrink: 0; }
|
|
198
|
+
.cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
|
|
199
|
+
.cpub-stage-advn { max-width: 320px; }
|
|
200
|
+
</style>
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ContestStageTemplateEditor — the per-stage SUBMISSION FORM builder, extracted
|
|
4
|
+
* from ContestStagesEditor so the (heaviest, flag-gated) part of the stage card is
|
|
5
|
+
* its own cohesive unit. Operates on ONE stage's `submissionTemplate` array and
|
|
6
|
+
* emits the whole new array (`update:template`); the pure array ops live in
|
|
7
|
+
* utils/contestStages.ts. The agreement/address field types + the per-field PII
|
|
8
|
+
* toggle are gated behind `features.contestPii` (rule #2); PII *access* is always
|
|
9
|
+
* gated server-side by the `contest.pii` permission regardless.
|
|
10
|
+
*/
|
|
11
|
+
import type { ContestSubmissionTemplateField } from '@commonpub/schema';
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
template: ContestSubmissionTemplateField[];
|
|
15
|
+
}>();
|
|
16
|
+
const emit = defineEmits<{ 'update:template': [template: ContestSubmissionTemplateField[]] }>();
|
|
17
|
+
|
|
18
|
+
const { features } = useFeatures();
|
|
19
|
+
const piiEnabled = computed(() => features.value.contestPii === true);
|
|
20
|
+
const FIELD_TYPES = computed<ContestSubmissionTemplateField['type'][]>(() => {
|
|
21
|
+
const base: ContestSubmissionTemplateField['type'][] = ['text', 'textarea', 'url', 'email', 'number', 'select', 'checkbox', 'date'];
|
|
22
|
+
if (piiEnabled.value) base.push('agreement', 'address');
|
|
23
|
+
return base;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function addField(): void {
|
|
27
|
+
emit('update:template', templateFieldAdded(props.template));
|
|
28
|
+
}
|
|
29
|
+
function labelInput(fi: number, e: Event): void {
|
|
30
|
+
emit('update:template', templateFieldLabelChanged(props.template, fi, (e.target as HTMLInputElement).value));
|
|
31
|
+
}
|
|
32
|
+
function setField(fi: number, patch: Partial<ContestSubmissionTemplateField>): void {
|
|
33
|
+
emit('update:template', templateFieldSet(props.template, fi, patch));
|
|
34
|
+
}
|
|
35
|
+
function changeType(fi: number, type: ContestSubmissionTemplateField['type']): void {
|
|
36
|
+
emit('update:template', templateFieldTypeChanged(props.template, fi, type));
|
|
37
|
+
}
|
|
38
|
+
function removeField(fi: number): void {
|
|
39
|
+
emit('update:template', templateFieldRemoved(props.template, fi));
|
|
40
|
+
}
|
|
41
|
+
function addOption(fi: number): void {
|
|
42
|
+
emit('update:template', templateOptionAdded(props.template, fi));
|
|
43
|
+
}
|
|
44
|
+
function setOption(fi: number, oi: number, patch: Partial<{ value: string; label: string }>): void {
|
|
45
|
+
emit('update:template', templateOptionSet(props.template, fi, oi, patch));
|
|
46
|
+
}
|
|
47
|
+
function removeOption(fi: number, oi: number): void {
|
|
48
|
+
emit('update:template', templateOptionRemoved(props.template, fi, oi));
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<div class="cpub-stage-criteria">
|
|
54
|
+
<div class="cpub-stage-criteria-head">
|
|
55
|
+
<span class="cpub-form-label" style="margin: 0;">Submission form, this stage</span>
|
|
56
|
+
<button type="button" class="cpub-btn cpub-btn-sm" @click="addField"><i class="fa-solid fa-plus"></i> Add field</button>
|
|
57
|
+
</div>
|
|
58
|
+
<p class="cpub-form-hint" style="margin: 4px 0;">Optional. Add fields entrants must fill for this stage (e.g. a proposal summary, or a repository link for a prototype round). Leave empty if entering a project is enough.</p>
|
|
59
|
+
<div v-for="(tf, fi) in template" :key="fi" class="cpub-stage-tfield">
|
|
60
|
+
<div class="cpub-stage-tfield-main">
|
|
61
|
+
<input
|
|
62
|
+
:value="tf.label"
|
|
63
|
+
type="text"
|
|
64
|
+
class="cpub-form-input"
|
|
65
|
+
placeholder="Field label (e.g. Repository URL)"
|
|
66
|
+
:aria-label="`Field ${fi + 1} label`"
|
|
67
|
+
@input="labelInput(fi, $event)"
|
|
68
|
+
/>
|
|
69
|
+
<select
|
|
70
|
+
:value="tf.type"
|
|
71
|
+
class="cpub-form-input cpub-stage-tfield-type"
|
|
72
|
+
:aria-label="`Field ${fi + 1} type`"
|
|
73
|
+
@change="changeType(fi, ($event.target as HTMLSelectElement).value as ContestSubmissionTemplateField['type'])"
|
|
74
|
+
>
|
|
75
|
+
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ TEMPLATE_FIELD_TYPE_LABEL[t] }}</option>
|
|
76
|
+
</select>
|
|
77
|
+
<label class="cpub-stage-tfield-req">
|
|
78
|
+
<input
|
|
79
|
+
type="checkbox"
|
|
80
|
+
:checked="tf.required"
|
|
81
|
+
:aria-label="`Field ${fi + 1} required`"
|
|
82
|
+
@change="setField(fi, { required: ($event.target as HTMLInputElement).checked })"
|
|
83
|
+
/>
|
|
84
|
+
<span>Required</span>
|
|
85
|
+
</label>
|
|
86
|
+
<button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove field" @click="removeField(fi)"><i class="fa-solid fa-xmark"></i></button>
|
|
87
|
+
</div>
|
|
88
|
+
<input
|
|
89
|
+
:value="tf.help ?? ''"
|
|
90
|
+
type="text"
|
|
91
|
+
class="cpub-form-input cpub-stage-tfield-help"
|
|
92
|
+
placeholder="Hint shown under the input (optional)"
|
|
93
|
+
:aria-label="`Field ${fi + 1} hint`"
|
|
94
|
+
@input="setField(fi, { help: ($event.target as HTMLInputElement).value || undefined })"
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
<!-- select: the allowed options -->
|
|
98
|
+
<div v-if="tf.type === 'select'" class="cpub-stage-tfield-extra">
|
|
99
|
+
<span class="cpub-form-hint" style="margin: 0;">Choices</span>
|
|
100
|
+
<div v-for="(opt, oi) in (tf.options ?? [])" :key="oi" class="cpub-stage-opt-row">
|
|
101
|
+
<input
|
|
102
|
+
:value="opt.label"
|
|
103
|
+
type="text"
|
|
104
|
+
class="cpub-form-input"
|
|
105
|
+
placeholder="Label (shown to entrants)"
|
|
106
|
+
:aria-label="`Field ${fi + 1} option ${oi + 1} label`"
|
|
107
|
+
@input="setOption(fi, oi, { label: ($event.target as HTMLInputElement).value })"
|
|
108
|
+
/>
|
|
109
|
+
<input
|
|
110
|
+
:value="opt.value"
|
|
111
|
+
type="text"
|
|
112
|
+
class="cpub-form-input"
|
|
113
|
+
placeholder="Value (stored)"
|
|
114
|
+
:aria-label="`Field ${fi + 1} option ${oi + 1} value`"
|
|
115
|
+
@input="setOption(fi, oi, { value: ($event.target as HTMLInputElement).value })"
|
|
116
|
+
/>
|
|
117
|
+
<button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove option" @click="removeOption(fi, oi)"><i class="fa-solid fa-xmark"></i></button>
|
|
118
|
+
</div>
|
|
119
|
+
<button type="button" class="cpub-btn cpub-btn-sm" @click="addOption(fi)"><i class="fa-solid fa-plus"></i> Add choice</button>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<!-- agreement: terms the entrant must accept -->
|
|
123
|
+
<div v-if="tf.type === 'agreement'" class="cpub-stage-tfield-extra">
|
|
124
|
+
<textarea
|
|
125
|
+
:value="tf.terms ?? ''"
|
|
126
|
+
class="cpub-form-input cpub-form-textarea"
|
|
127
|
+
rows="3"
|
|
128
|
+
placeholder="Terms the entrant must accept (e.g. shipping the hardware to winners)"
|
|
129
|
+
:aria-label="`Field ${fi + 1} agreement terms`"
|
|
130
|
+
@input="setField(fi, { terms: ($event.target as HTMLTextAreaElement).value || undefined })"
|
|
131
|
+
></textarea>
|
|
132
|
+
<label class="cpub-stage-tfield-req">
|
|
133
|
+
<input
|
|
134
|
+
type="checkbox"
|
|
135
|
+
:checked="tf.mustAccept !== false"
|
|
136
|
+
:aria-label="`Field ${fi + 1} must accept`"
|
|
137
|
+
@change="setField(fi, { mustAccept: ($event.target as HTMLInputElement).checked })"
|
|
138
|
+
/>
|
|
139
|
+
<span>Must accept to submit</span>
|
|
140
|
+
</label>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<!-- address: structured + always personal data -->
|
|
144
|
+
<p v-if="tf.type === 'address'" class="cpub-form-hint" style="margin: 4px 0;">
|
|
145
|
+
Collected as a structured mailing address and stored as personal data. Visible only to staff with PII access and the entrant.
|
|
146
|
+
</p>
|
|
147
|
+
|
|
148
|
+
<!-- PII toggle (non-address, non-agreement scalar fields) -->
|
|
149
|
+
<label
|
|
150
|
+
v-if="piiEnabled && tf.type !== 'address' && tf.type !== 'agreement'"
|
|
151
|
+
class="cpub-stage-tfield-req cpub-stage-tfield-pii"
|
|
152
|
+
>
|
|
153
|
+
<input
|
|
154
|
+
type="checkbox"
|
|
155
|
+
:checked="tf.pii === true"
|
|
156
|
+
:aria-label="`Field ${fi + 1} is personal data`"
|
|
157
|
+
@change="setField(fi, { pii: ($event.target as HTMLInputElement).checked || undefined })"
|
|
158
|
+
/>
|
|
159
|
+
<span>Personal data (store privately, hide from the public listing)</span>
|
|
160
|
+
</label>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</template>
|
|
164
|
+
|
|
165
|
+
<style scoped>
|
|
166
|
+
/* Scoped CSS doesn't cross component boundaries — the form-control + template-field
|
|
167
|
+
styles travel with this extracted markup (the global theme only provides
|
|
168
|
+
.cpub-form-label/.cpub-form-hint/.cpub-btn). */
|
|
169
|
+
.cpub-form-input, .cpub-form-textarea { width: 100%; padding: var(--space-2) var(--space-3); border: var(--border-width-default) solid var(--border); background: var(--surface); color: var(--text); font-size: var(--text-sm); font-family: var(--font-sans); }
|
|
170
|
+
.cpub-form-input:focus, .cpub-form-textarea:focus { border-color: var(--accent); outline: none; box-shadow: var(--shadow-accent); }
|
|
171
|
+
.cpub-form-textarea { resize: vertical; }
|
|
172
|
+
|
|
173
|
+
.cpub-stage-iconbtn { background: var(--surface); border: var(--border-width-default) solid var(--border); color: var(--text-dim); cursor: pointer; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; }
|
|
174
|
+
.cpub-stage-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
|
|
175
|
+
.cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
|
|
176
|
+
|
|
177
|
+
.cpub-stage-criteria { border: var(--border-width-default) dashed var(--border2); padding: 10px; margin-top: 4px; background: var(--surface); }
|
|
178
|
+
.cpub-stage-criteria-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
179
|
+
.cpub-stage-tfield { margin-top: 8px; padding-top: 8px; border-top: var(--border-width-default) dashed var(--border2); }
|
|
180
|
+
.cpub-stage-tfield:first-of-type { border-top: 0; padding-top: 0; }
|
|
181
|
+
.cpub-stage-tfield-main { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
182
|
+
.cpub-stage-tfield-main .cpub-form-input { flex: 2; min-width: 140px; margin: 0; }
|
|
183
|
+
.cpub-stage-tfield-type { flex: 1 !important; min-width: 110px !important; max-width: 150px; }
|
|
184
|
+
.cpub-stage-tfield-req { display: inline-flex; align-items: center; gap: 5px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); cursor: pointer; flex-shrink: 0; }
|
|
185
|
+
.cpub-stage-tfield-req input { width: 13px; height: 13px; }
|
|
186
|
+
.cpub-stage-tfield-help { margin-top: 6px !important; font-size: var(--text-xs) !important; }
|
|
187
|
+
.cpub-stage-tfield-extra { margin-top: 6px; padding: 8px; border: var(--border-width-default) dashed var(--border2); background: var(--surface2); display: flex; flex-direction: column; gap: 6px; }
|
|
188
|
+
.cpub-stage-opt-row { display: flex; align-items: center; gap: 6px; }
|
|
189
|
+
.cpub-stage-opt-row .cpub-form-input { flex: 1; min-width: 100px; margin: 0; }
|
|
190
|
+
.cpub-stage-tfield-pii { margin-top: 6px; }
|
|
191
|
+
</style>
|