@commonpub/layer 0.84.0 → 0.86.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/blocks/BlockEmbedView.vue +14 -2
- package/components/blocks/BlockVideoView.vue +19 -2
- package/components/contest/ContestBannerAdjust.vue +157 -0
- package/components/contest/ContestEditor.vue +52 -6
- package/components/contest/ContestHero.vue +31 -3
- package/components/contest/ContestProposalForm.vue +3 -0
- package/components/contest/ContestStageCard.vue +7 -0
- package/components/contest/ContestStageSubmission.vue +3 -0
- package/components/contest/ContestStageTemplateEditor.vue +191 -8
- package/components/contest/blocks/JudgesShowcaseBlock.vue +113 -6
- package/composables/useContestEditor.ts +19 -3
- package/package.json +8 -8
- package/pages/contests/[slug]/index.vue +54 -30
- package/pages/contests/index.vue +1 -0
- package/utils/contestBlocks.ts +10 -0
- package/utils/contestBody.ts +3 -3
- package/utils/contestImage.ts +45 -0
- package/utils/contestSubmissionTemplates.ts +165 -0
- package/utils/contestTemplates.ts +3 -0
|
@@ -267,6 +267,30 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
267
267
|
@copy-link="copyLink"
|
|
268
268
|
/>
|
|
269
269
|
|
|
270
|
+
<!-- Section tabs — a prominent centered band directly under the hero. -->
|
|
271
|
+
<nav class="cpub-ctabs" aria-label="Contest sections">
|
|
272
|
+
<div class="cpub-ctabs-inner" role="tablist">
|
|
273
|
+
<button
|
|
274
|
+
v-for="tab in tabs"
|
|
275
|
+
:id="`cpub-tab-${tab.key}`"
|
|
276
|
+
:key="tab.key"
|
|
277
|
+
role="tab"
|
|
278
|
+
type="button"
|
|
279
|
+
class="cpub-ctab"
|
|
280
|
+
:class="{ 'cpub-ctab-active': activeTab === tab.key }"
|
|
281
|
+
:aria-selected="activeTab === tab.key"
|
|
282
|
+
:aria-controls="`cpub-panel-${tab.key}`"
|
|
283
|
+
:tabindex="activeTab === tab.key ? 0 : -1"
|
|
284
|
+
@click="activeTab = tab.key"
|
|
285
|
+
@keydown="onTabKey($event, tab.key)"
|
|
286
|
+
>
|
|
287
|
+
<i class="fa-solid" :class="tab.icon"></i>
|
|
288
|
+
<span class="cpub-ctab-label">{{ tab.label }}</span>
|
|
289
|
+
<span v-if="tab.count != null" class="cpub-ctab-count">{{ tab.count }}</span>
|
|
290
|
+
</button>
|
|
291
|
+
</div>
|
|
292
|
+
</nav>
|
|
293
|
+
|
|
270
294
|
<!-- SUBMIT ENTRY DIALOG -->
|
|
271
295
|
<div v-if="showSubmitDialog" class="cpub-submit-overlay" @click.self="showSubmitDialog = false">
|
|
272
296
|
<div ref="submitDialogRef" class="cpub-submit-dialog" role="dialog" aria-modal="true" aria-label="Submit entry">
|
|
@@ -340,32 +364,19 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
340
364
|
<span>{{ visibilityNote.text }}</span>
|
|
341
365
|
</div>
|
|
342
366
|
|
|
343
|
-
<!-- Tab bar -->
|
|
344
|
-
<div class="cpub-tabbar" role="tablist" aria-label="Contest sections">
|
|
345
|
-
<button
|
|
346
|
-
v-for="tab in tabs"
|
|
347
|
-
:id="`cpub-tab-${tab.key}`"
|
|
348
|
-
:key="tab.key"
|
|
349
|
-
role="tab"
|
|
350
|
-
type="button"
|
|
351
|
-
class="cpub-tab"
|
|
352
|
-
:class="{ 'cpub-tab-active': activeTab === tab.key }"
|
|
353
|
-
:aria-selected="activeTab === tab.key"
|
|
354
|
-
:aria-controls="`cpub-panel-${tab.key}`"
|
|
355
|
-
:tabindex="activeTab === tab.key ? 0 : -1"
|
|
356
|
-
@click="activeTab = tab.key"
|
|
357
|
-
@keydown="onTabKey($event, tab.key)"
|
|
358
|
-
>
|
|
359
|
-
<i class="fa-solid" :class="tab.icon"></i> {{ tab.label }}
|
|
360
|
-
<span v-if="tab.count != null" class="cpub-tab-count">{{ tab.count }}</span>
|
|
361
|
-
</button>
|
|
362
|
-
</div>
|
|
363
367
|
|
|
364
368
|
<!-- OVERVIEW -->
|
|
365
369
|
<div v-show="activeTab === 'overview'" id="cpub-panel-overview" role="tabpanel" aria-labelledby="cpub-tab-overview" tabindex="0">
|
|
366
370
|
<div class="cpub-about-section">
|
|
367
371
|
<div class="cpub-sec-head"><h2><i class="fa fa-circle-info" style="color: var(--accent);"></i> About This Contest</h2></div>
|
|
368
|
-
<img
|
|
372
|
+
<img
|
|
373
|
+
v-if="c?.coverImageUrl && c?.coverPlacement !== 'hero'"
|
|
374
|
+
:src="c.coverImageUrl"
|
|
375
|
+
:alt="`${c?.title || 'Contest'} cover`"
|
|
376
|
+
class="cpub-about-cover"
|
|
377
|
+
:class="{ 'cpub-about-cover--whole': isWholeImage(c?.coverMeta) }"
|
|
378
|
+
:style="isWholeImage(c?.coverMeta) ? undefined : imageFramingStyle(c?.coverMeta)"
|
|
379
|
+
/>
|
|
369
380
|
<div class="cpub-about-card">
|
|
370
381
|
<BlocksBlockContentRenderer
|
|
371
382
|
v-if="c?.descriptionBlocks?.length"
|
|
@@ -506,7 +517,10 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
506
517
|
.cpub-submit-footer { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: var(--border-width-default) solid var(--border); }
|
|
507
518
|
|
|
508
519
|
/* LAYOUT */
|
|
509
|
-
|
|
520
|
+
/* Top padding intentionally tighter than the sides: the hero bar already sits
|
|
521
|
+
directly above with its own 20px bottom padding + border, so a full 32px here
|
|
522
|
+
stacked to a ~52px hero→tabs gap. ~18px lands the tabbar close under the hero. */
|
|
523
|
+
.cpub-contest-main { max-width: 1100px; margin: 0 auto; padding: 18px 32px 32px; }
|
|
510
524
|
|
|
511
525
|
.cpub-entries-tools { display: flex; justify-content: flex-end; margin-bottom: 12px; }
|
|
512
526
|
.cpub-entries-cta { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; padding: 16px 20px; margin-bottom: 18px; background: var(--accent-bg); border: var(--border-width-default) solid var(--accent-border); }
|
|
@@ -522,14 +536,23 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
522
536
|
.cpub-invite-text i { color: var(--accent); }
|
|
523
537
|
|
|
524
538
|
/* TABS */
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
.cpub-
|
|
529
|
-
.cpub-
|
|
530
|
-
.cpub-
|
|
531
|
-
.cpub-
|
|
532
|
-
.cpub-
|
|
539
|
+
/* Section tabs — a prominent centered nav band directly under the hero. Larger
|
|
540
|
+
bold mono labels; the active tab gets an accent tint + thick accent underline so
|
|
541
|
+
it clearly reads as selected even when there are only a couple of tabs. */
|
|
542
|
+
.cpub-ctabs { background: var(--surface); border-bottom: 2px solid var(--border); box-shadow: var(--shadow-sm); }
|
|
543
|
+
.cpub-ctabs-inner { max-width: 1100px; margin: 0 auto; padding: 0 24px; display: flex; justify-content: center; flex-wrap: wrap; }
|
|
544
|
+
.cpub-ctab { display: inline-flex; align-items: center; gap: 9px; padding: 17px 28px; min-height: 58px; background: none; border: none; border-bottom: 3px solid transparent; margin-bottom: -2px; font-family: var(--font-mono); font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--text-faint); cursor: pointer; transition: color .12s, background .12s; }
|
|
545
|
+
.cpub-ctab:hover { color: var(--text); background: var(--surface2); }
|
|
546
|
+
.cpub-ctab-active { color: var(--accent); border-bottom-color: var(--accent); background: var(--accent-bg); }
|
|
547
|
+
.cpub-ctab i { font-size: 14px; }
|
|
548
|
+
.cpub-ctab-count { font-size: 10px; font-weight: 700; padding: 2px 8px; background: var(--surface2); border: var(--border-width-default) solid var(--border2); color: var(--text-dim); }
|
|
549
|
+
.cpub-ctab-active .cpub-ctab-count { background: var(--surface); border-color: var(--accent-border); color: var(--accent); }
|
|
550
|
+
@media (max-width: 640px) {
|
|
551
|
+
.cpub-ctabs-inner { justify-content: flex-start; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
|
|
552
|
+
.cpub-ctabs-inner::-webkit-scrollbar { display: none; }
|
|
553
|
+
.cpub-ctab { flex-shrink: 0; padding: 14px 18px; min-height: 50px; font-size: 12px; }
|
|
554
|
+
.cpub-ctab-label { white-space: nowrap; }
|
|
555
|
+
}
|
|
533
556
|
|
|
534
557
|
[role="tabpanel"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
|
535
558
|
|
|
@@ -556,6 +579,7 @@ async function withdrawEntry(entryId: string): Promise<void> {
|
|
|
556
579
|
/* ABOUT */
|
|
557
580
|
.cpub-about-section { margin-bottom: 20px; }
|
|
558
581
|
.cpub-about-cover { width: 100%; max-height: 380px; object-fit: cover; display: block; border: var(--border-width-default) solid var(--border); box-shadow: var(--shadow-md); margin-bottom: 16px; }
|
|
582
|
+
.cpub-about-cover--whole { height: auto; max-height: 480px; object-fit: contain; }
|
|
559
583
|
.cpub-about-card { background: var(--surface); border: var(--border-width-default) solid var(--border); border-radius: var(--radius); padding: 20px; box-shadow: var(--shadow-md); font-size: 12px; color: var(--text-dim); line-height: 1.7; }
|
|
560
584
|
.cpub-about-card p { margin: 0; white-space: pre-line; }
|
|
561
585
|
|
package/pages/contests/index.vue
CHANGED
|
@@ -65,6 +65,7 @@ const canCreateContest = computed(() => {
|
|
|
65
65
|
:alt="contest.title"
|
|
66
66
|
class="cpub-contest-cover"
|
|
67
67
|
:class="{ 'cpub-contest-cover--contain': !contest.coverImageUrl && !!contest.bannerUrl }"
|
|
68
|
+
:style="contest.coverImageUrl ? imageFramingStyle(contest.coverMeta) : undefined"
|
|
68
69
|
loading="lazy"
|
|
69
70
|
/>
|
|
70
71
|
<template v-else>
|
package/utils/contestBlocks.ts
CHANGED
|
@@ -19,6 +19,16 @@ export const CONTEST_RUBRIC_KEY: InjectionKey<Ref<ContestRubricCriterion[]>> = S
|
|
|
19
19
|
* "pull from schedule" seed. Absent (null) outside the contest editor. */
|
|
20
20
|
export const CONTEST_SCHEDULE_KEY: InjectionKey<Ref<RoadmapItem[]>> = Symbol('contestSchedule');
|
|
21
21
|
|
|
22
|
+
/** A panel judge as the Judges Showcase block cares about it (for "Import panel
|
|
23
|
+
* judges": seed showcase rows from the real scoring panel). */
|
|
24
|
+
export interface ContestPanelJudge { name: string; avatarUrl?: string; title?: string; link?: string }
|
|
25
|
+
|
|
26
|
+
/** ContestEditor `provide`s an async loader for the contest's scoring-panel
|
|
27
|
+
* judges under this key, so the Judges Showcase block can offer a one-click
|
|
28
|
+
* "import panel judges" seed (name + account avatar). Resolves to [] in create
|
|
29
|
+
* mode (no slug yet); absent (null) outside the contest editor. */
|
|
30
|
+
export const CONTEST_JUDGES_KEY: InjectionKey<() => Promise<ContestPanelJudge[]>> = Symbol('contestJudges');
|
|
31
|
+
|
|
22
32
|
/** A stage as the roadmap cares about it (structural subset of ContestStage). */
|
|
23
33
|
export interface RoadmapStageSource { name: string; kind?: string; startsAt?: string; endsAt?: string; description?: string }
|
|
24
34
|
/** The three core schedule dates, when there are no custom stages. */
|
package/utils/contestBody.ts
CHANGED
|
@@ -15,11 +15,11 @@ export function seedBodyBlocks(
|
|
|
15
15
|
if (Array.isArray(blocks) && blocks.length) return blocks as BlockTuple[];
|
|
16
16
|
const text = (legacy ?? '').trim();
|
|
17
17
|
if (!text) return [];
|
|
18
|
-
if (legacyFormat === 'html') return [['markdown', {
|
|
18
|
+
if (legacyFormat === 'html') return [['markdown', { source: text }]];
|
|
19
19
|
try {
|
|
20
20
|
const parsed = markdownToBlockTuples(text);
|
|
21
|
-
return parsed.length ? parsed : [['markdown', {
|
|
21
|
+
return parsed.length ? parsed : [['markdown', { source: text }]];
|
|
22
22
|
} catch {
|
|
23
|
-
return [['markdown', {
|
|
23
|
+
return [['markdown', { source: text }]];
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { CSSProperties } from 'vue';
|
|
2
|
+
import type { ContestImageMeta } from '@commonpub/schema';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Map a contest banner/cover `ContestImageMeta` to CSS for the <img> (P4). The
|
|
6
|
+
* framing is NON-DESTRUCTIVE — the original upload is never re-cropped; this only
|
|
7
|
+
* drives object-fit / transform / object-position. Shared by ContestHero (public
|
|
8
|
+
* render), the editor preview, and ContestBannerAdjust so all three agree.
|
|
9
|
+
*
|
|
10
|
+
* - `null`/absent ⇒ `cover` with NO transform — the legacy fit, so existing
|
|
11
|
+
* contests look identical until an organiser touches the framing (back-compat).
|
|
12
|
+
* - `zoom <= 0` ⇒ `contain` — perfect fit, the whole image visible (letterboxed).
|
|
13
|
+
* - `zoom > 0` ⇒ `cover` + `scale(1 + zoom)` + `object-position: x% y%`.
|
|
14
|
+
*/
|
|
15
|
+
/** A `:style` object (a Vue CSSProperties subset) for an <img>. */
|
|
16
|
+
export type ImageFraming = CSSProperties;
|
|
17
|
+
|
|
18
|
+
export function imageFramingStyle(meta: ContestImageMeta | null | undefined): CSSProperties {
|
|
19
|
+
if (!meta) return { objectFit: 'cover' };
|
|
20
|
+
const x = clampPct(meta.x);
|
|
21
|
+
const y = clampPct(meta.y);
|
|
22
|
+
if (meta.zoom <= 0) return { objectFit: 'contain', objectPosition: `${x}% ${y}%` };
|
|
23
|
+
const zoom = Math.min(4, meta.zoom);
|
|
24
|
+
return { objectFit: 'cover', transform: `scale(${1 + zoom})`, objectPosition: `${x}% ${y}%` };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clampPct(n: number): number {
|
|
28
|
+
if (!Number.isFinite(n)) return 50;
|
|
29
|
+
return Math.max(0, Math.min(100, Math.round(n)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The default framing an organiser starts from when they first adjust an image. */
|
|
33
|
+
export function defaultImageMeta(): ContestImageMeta {
|
|
34
|
+
return { zoom: 0, x: 50, y: 50 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* True when the framing means "show the WHOLE image" (Fit, zoom 0). Surfaces that
|
|
39
|
+
* can grow (the hero banner band, the editor preview) render this as a natural-ratio
|
|
40
|
+
* image (no crop, no letterbox bars) rather than `contain` inside a fixed band.
|
|
41
|
+
* null/absent = the legacy cover fit, so this is false (existing banners unchanged).
|
|
42
|
+
*/
|
|
43
|
+
export function isWholeImage(meta: ContestImageMeta | null | undefined): boolean {
|
|
44
|
+
return !!meta && meta.zoom <= 0;
|
|
45
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { ContestSubmissionTemplateField } from '@commonpub/schema';
|
|
2
|
+
import { fieldKeyFromLabel } from './contestStages';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Field presets + whole-form templates for the submission-form builder (P2). Pure
|
|
6
|
+
* data + helpers so they unit-test in isolation; the builder UI
|
|
7
|
+
* (ContestStageTemplateEditor) appends a preset or replaces the whole form via the
|
|
8
|
+
* `templatePreset*`/template builders here. Keys are derived from the label and
|
|
9
|
+
* uniquified against the existing template so two "Email" fields don't collide
|
|
10
|
+
* (template field keys must be unique — `contestStageSchema`).
|
|
11
|
+
*
|
|
12
|
+
* Address/Agreement presets + the address/shipping templates are PII-gated
|
|
13
|
+
* (`features.contestPii`): the agreement/address field types are only offered in
|
|
14
|
+
* the builder when that flag is on, so the UI hides them otherwise. The pure
|
|
15
|
+
* builders take an explicit `{ pii }` so they degrade the same way in isolation.
|
|
16
|
+
*/
|
|
17
|
+
type TemplateField = ContestSubmissionTemplateField;
|
|
18
|
+
|
|
19
|
+
/** Default terms an organiser can keep or edit; shared by the preset + template. */
|
|
20
|
+
export const RULES_AGREEMENT_TERMS =
|
|
21
|
+
'By entering, I confirm this submission is my own original work and I agree to the contest rules and code of conduct.';
|
|
22
|
+
|
|
23
|
+
/** Make `base` unique within `taken` by appending `_2`, `_3`, … */
|
|
24
|
+
function uniqueKey(taken: Set<string>, base: string): string {
|
|
25
|
+
if (!taken.has(base)) return base;
|
|
26
|
+
let n = 2;
|
|
27
|
+
while (taken.has(`${base}_${n}`)) n += 1;
|
|
28
|
+
return `${base}_${n}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Stamp keys on a set of keyless fields, keeping them unique among themselves. */
|
|
32
|
+
function withKeys(fields: Array<Omit<TemplateField, 'key'> & { key?: string }>): TemplateField[] {
|
|
33
|
+
const taken = new Set<string>();
|
|
34
|
+
return fields.map((f) => {
|
|
35
|
+
const key = uniqueKey(taken, f.key || fieldKeyFromLabel(f.label));
|
|
36
|
+
taken.add(key);
|
|
37
|
+
return { ...f, key };
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── One-click field presets (the "Add field" menu) ───
|
|
42
|
+
|
|
43
|
+
export interface FieldPreset {
|
|
44
|
+
id: string;
|
|
45
|
+
/** Menu label. */
|
|
46
|
+
label: string;
|
|
47
|
+
/** FontAwesome icon (no `fa-solid` prefix). */
|
|
48
|
+
icon: string;
|
|
49
|
+
/** Requires `features.contestPii` (agreement/address field types). */
|
|
50
|
+
pii?: boolean;
|
|
51
|
+
/** The field this preset seeds (key derived from the label at add time). */
|
|
52
|
+
field: Omit<TemplateField, 'key'>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const FIELD_PRESETS: FieldPreset[] = [
|
|
56
|
+
{ id: 'text', label: 'Short text', icon: 'fa-font', field: { label: 'Short answer', type: 'text', required: false } },
|
|
57
|
+
{ id: 'textarea', label: 'Long text', icon: 'fa-align-left', field: { label: 'Details', type: 'textarea', required: false } },
|
|
58
|
+
{ id: 'url', label: 'Link (URL)', icon: 'fa-link', field: { label: 'Link', type: 'url', required: false, help: 'Include the full https:// address.' } },
|
|
59
|
+
{ id: 'email', label: 'Email', icon: 'fa-envelope', field: { label: 'Email address', type: 'email', required: false } },
|
|
60
|
+
{ id: 'number', label: 'Number', icon: 'fa-hashtag', field: { label: 'Number', type: 'number', required: false } },
|
|
61
|
+
{ id: 'select', label: 'Dropdown', icon: 'fa-list', field: { label: 'Choose one', type: 'select', required: false, options: [{ value: '', label: '' }] } },
|
|
62
|
+
{ id: 'checkbox', label: 'Checkbox', icon: 'fa-square-check', field: { label: 'Confirm', type: 'checkbox', required: false } },
|
|
63
|
+
{ id: 'date', label: 'Date', icon: 'fa-calendar', field: { label: 'Date', type: 'date', required: false } },
|
|
64
|
+
{
|
|
65
|
+
id: 'address',
|
|
66
|
+
label: 'Mailing address',
|
|
67
|
+
icon: 'fa-location-dot',
|
|
68
|
+
pii: true,
|
|
69
|
+
field: { label: 'Mailing address', type: 'address', required: false, pii: true, help: 'Stored privately. Only staff with PII access and the entrant can read it.' },
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'agreement',
|
|
73
|
+
label: 'Agreement',
|
|
74
|
+
icon: 'fa-file-signature',
|
|
75
|
+
pii: true,
|
|
76
|
+
field: { label: 'Agreement', type: 'agreement', required: true, mustAccept: true, terms: RULES_AGREEMENT_TERMS },
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** Presets offered for the builder, gated by whether PII field types are enabled. */
|
|
81
|
+
export function availableFieldPresets(pii: boolean): FieldPreset[] {
|
|
82
|
+
return pii ? FIELD_PRESETS : FIELD_PRESETS.filter((p) => !p.pii);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Append a preset field, deriving a unique machine key from its label. */
|
|
86
|
+
export function templatePresetAdded(t: TemplateField[], preset: FieldPreset): TemplateField[] {
|
|
87
|
+
const taken = new Set(t.map((f) => f.key));
|
|
88
|
+
const key = uniqueKey(taken, fieldKeyFromLabel(preset.field.label));
|
|
89
|
+
return [...t, { ...preset.field, key }];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Whole-form templates (the "Start from template" picker) ───
|
|
93
|
+
|
|
94
|
+
export interface SubmissionFormTemplate {
|
|
95
|
+
id: string;
|
|
96
|
+
label: string;
|
|
97
|
+
description: string;
|
|
98
|
+
/** Requires `features.contestPii` to seed its address/agreement fields. */
|
|
99
|
+
pii?: boolean;
|
|
100
|
+
/** Build the field array; flag-adaptive so it degrades when PII is off. */
|
|
101
|
+
build(opts: { pii: boolean }): TemplateField[];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const SHIPPING_AGREEMENT_TERMS =
|
|
105
|
+
'If selected, I agree to provide a valid shipping address and accept responsibility for any hardware sent to me.';
|
|
106
|
+
|
|
107
|
+
export const SUBMISSION_FORM_TEMPLATES: SubmissionFormTemplate[] = [
|
|
108
|
+
{
|
|
109
|
+
id: 'standard',
|
|
110
|
+
label: 'Standard proposal',
|
|
111
|
+
description: 'Name, summary, description, approach (and a rules agreement when PII is on).',
|
|
112
|
+
build({ pii }): TemplateField[] {
|
|
113
|
+
const fields: Array<Omit<TemplateField, 'key'>> = [
|
|
114
|
+
{ label: 'Project name', type: 'text', required: true },
|
|
115
|
+
{ label: 'One-line summary', type: 'text', required: true, help: 'A single sentence describing your idea.' },
|
|
116
|
+
{ label: 'Description', type: 'textarea', required: true, help: 'What you are building and the problem it solves.' },
|
|
117
|
+
{ label: 'Approach', type: 'textarea', required: false, help: 'How you plan to build it (optional).' },
|
|
118
|
+
];
|
|
119
|
+
if (pii) fields.push({ label: 'Contest rules', type: 'agreement', required: true, terms: RULES_AGREEMENT_TERMS, mustAccept: true });
|
|
120
|
+
return withKeys(fields);
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
id: 'hardware',
|
|
125
|
+
label: 'Hardware / shipping',
|
|
126
|
+
description: 'Standard proposal plus a mailing address and a shipping agreement (PII).',
|
|
127
|
+
pii: true,
|
|
128
|
+
build({ pii }): TemplateField[] {
|
|
129
|
+
const fields: Array<Omit<TemplateField, 'key'>> = [
|
|
130
|
+
{ label: 'Project name', type: 'text', required: true },
|
|
131
|
+
{ label: 'One-line summary', type: 'text', required: true, help: 'A single sentence describing your idea.' },
|
|
132
|
+
{ label: 'Description', type: 'textarea', required: true, help: 'What you are building and the problem it solves.' },
|
|
133
|
+
];
|
|
134
|
+
if (pii) {
|
|
135
|
+
fields.push({ label: 'Mailing address', type: 'address', required: true, pii: true, help: 'Stored privately. Only staff with PII access and the entrant can read it.' });
|
|
136
|
+
fields.push({ label: 'Shipping agreement', type: 'agreement', required: true, terms: SHIPPING_AGREEMENT_TERMS, mustAccept: true });
|
|
137
|
+
}
|
|
138
|
+
return withKeys(fields);
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: 'minimal',
|
|
143
|
+
label: 'Minimal',
|
|
144
|
+
description: 'Just a project name and a link.',
|
|
145
|
+
build(): TemplateField[] {
|
|
146
|
+
return withKeys([
|
|
147
|
+
{ label: 'Project name', type: 'text', required: true },
|
|
148
|
+
{ label: 'Link', type: 'url', required: false, help: 'Include the full https:// address.' },
|
|
149
|
+
]);
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
id: 'blank',
|
|
154
|
+
label: 'Blank',
|
|
155
|
+
description: 'Start with no fields.',
|
|
156
|
+
build(): TemplateField[] {
|
|
157
|
+
return [];
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
/** Templates offered for the builder, gated by whether PII field types are enabled. */
|
|
163
|
+
export function availableFormTemplates(pii: boolean): SubmissionFormTemplate[] {
|
|
164
|
+
return pii ? SUBMISSION_FORM_TEMPLATES : SUBMISSION_FORM_TEMPLATES.filter((t) => !t.pii);
|
|
165
|
+
}
|
|
@@ -87,6 +87,9 @@ export function standardContestTemplate(opts: StandardTemplateOptions): ContestT
|
|
|
87
87
|
description: 'Entrants submit a proposal for review.',
|
|
88
88
|
submissionMode: opts.proposals ? 'proposal' : 'attach',
|
|
89
89
|
submissionTemplate: standardSubmissionTemplate(opts),
|
|
90
|
+
instructionsBlocks: markdownToBlockTuples(
|
|
91
|
+
'Tell us about your idea. Be concrete about what you will build and why it matters. You can edit your proposal until the round closes.',
|
|
92
|
+
) as ContestStage['instructionsBlocks'],
|
|
90
93
|
},
|
|
91
94
|
{
|
|
92
95
|
id: newStageId(),
|