@commonpub/layer 0.51.0 → 0.52.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.
@@ -84,31 +84,10 @@ const dateNote = computed<string | null>(() => {
84
84
  return c.value?.endDate ? `Closed ${fmtDate(c.value.endDate)}` : null;
85
85
  });
86
86
 
87
- // Client-side mirror of the server VALID_TRANSITIONS map (server/src/contest/contest.ts).
88
- // Keeps the inline admin controls in sync with what the API will actually accept —
89
- // bidirectional: go back a stage, pause/resume, reopen, etc.
90
- const VALID_TRANSITIONS: Record<string, string[]> = {
91
- draft: ['upcoming', 'active', 'cancelled'],
92
- upcoming: ['draft', 'active', 'cancelled'],
93
- active: ['upcoming', 'paused', 'judging', 'cancelled'],
94
- paused: ['active', 'upcoming', 'judging', 'cancelled'],
95
- judging: ['active', 'paused', 'completed', 'cancelled'],
96
- completed: ['judging'],
97
- cancelled: ['draft', 'upcoming'],
98
- };
99
- const STATUS_ACTION: Record<string, { label: string; icon: string }> = {
100
- draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
101
- upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
102
- active: { label: 'Activate', icon: 'fa-play' },
103
- paused: { label: 'Pause', icon: 'fa-pause' },
104
- judging: { label: 'Start Judging', icon: 'fa-gavel' },
105
- completed: { label: 'Complete', icon: 'fa-check' },
106
- cancelled: { label: 'Cancel', icon: 'fa-ban' },
107
- };
108
- const availableTransitions = computed<string[]>(() => VALID_TRANSITIONS[c.value?.status ?? 'upcoming'] ?? []);
109
- function statusAction(s: string): { label: string; icon: string } {
110
- return STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
111
- }
87
+ // Bidirectional lifecycle controls the valid-transition map + button metadata
88
+ // live in utils/contestTransitions.ts (shared with the contest edit page).
89
+ const availableTransitions = computed<string[]>(() => contestTransitionsFrom(c.value?.status));
90
+ const statusAction = contestStatusAction;
112
91
 
113
92
  // The hero shows the short `subheading` (a dedicated tagline field). For older
114
93
  // contests without one, fall back to a clean, plain-text, CSS-clamped excerpt of
@@ -120,6 +99,15 @@ const tagline = computed<string>(() => {
120
99
  return markdownToExcerpt(c.value?.description) || '';
121
100
  });
122
101
 
102
+ // Phase B1 — when a contest defines explicit stages, surface the current stage's
103
+ // name beside the status pill (default-flow contests show nothing extra).
104
+ const currentStageName = computed<string | null>(() => {
105
+ const cv = c.value;
106
+ if (!cv || !cv.stages || cv.stages.length === 0) return null;
107
+ const cid = currentStageId(cv);
108
+ return cv.stages.find((s) => s.id === cid)?.name ?? null;
109
+ });
110
+
123
111
  const dateRange = computed<string>(() => {
124
112
  const fmt = (d: string, withYear = false) =>
125
113
  new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', ...(withYear ? { year: 'numeric' } : {}) });
@@ -157,6 +145,7 @@ const dateRange = computed<string>(() => {
157
145
  <div class="cpub-hero-eyebrow">
158
146
  <span class="cpub-contest-badge"><i class="fa fa-trophy"></i> Contest</span>
159
147
  <span class="cpub-status-pill" :data-status="c?.status || 'upcoming'">{{ c?.status || 'upcoming' }}</span>
148
+ <span v-if="currentStageName" class="cpub-stage-chip"><i class="fa-solid fa-diagram-project"></i> {{ currentStageName }}</span>
160
149
  </div>
161
150
 
162
151
  <h1 class="cpub-hero-title">{{ c?.title || 'Contest' }}</h1>
@@ -290,6 +279,8 @@ const dateRange = computed<string>(() => {
290
279
  .cpub-status-pill[data-status="paused"] { color: var(--yellow); border-color: var(--yellow); background: color-mix(in srgb, var(--yellow) 12%, transparent); }
291
280
  .cpub-status-pill[data-status="draft"] { color: var(--hero-text-dim); border-color: var(--hero-border); border-style: dashed; }
292
281
  .cpub-status-pill[data-status="completed"], .cpub-status-pill[data-status="cancelled"] { color: var(--red); border-color: var(--red-border); }
282
+ .cpub-stage-chip { font-size: 9px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; font-family: var(--font-mono); padding: 3px 10px; border-radius: var(--radius); border: var(--border-width-default) solid var(--accent); color: var(--accent); background: var(--accent-bg); display: inline-flex; align-items: center; gap: 5px; }
283
+ .cpub-stage-chip i { font-size: 8px; }
293
284
 
294
285
  .cpub-hero-title { font-size: 34px; font-weight: 800; letter-spacing: -.03em; line-height: 1.1; margin: 0 0 10px; color: var(--hero-text); }
295
286
  .cpub-hero-tagline { font-size: 14px; color: var(--hero-text-dim); line-height: 1.55; max-width: 600px; margin: 0 0 20px; display: -webkit-box; -webkit-line-clamp: 4; line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
@@ -20,24 +20,21 @@ function fmt(d: string | null | undefined): string | null {
20
20
  return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
21
21
  }
22
22
 
23
- // Ordinal position of each status along the lifecycle, used to mark steps
24
- // done / current / upcoming.
25
- // `draft` precedes everything (nothing has started); `paused` sits at the active
26
- // position (a deactivated-but-not-cancelled running contest).
27
- const STATUS_ORDER: Record<string, number> = { draft: -1, upcoming: 0, active: 1, paused: 1, judging: 2, completed: 3 };
28
-
23
+ // Phase B1 the timeline renders the contest's stages (its explicit `stages`, or
24
+ // the synthesized classic Submissions → Judging → Results when none are defined).
25
+ // done/current/upcoming derive from the position of the current stage.
29
26
  const timeline = computed<TimelineStep[]>(() => {
30
27
  const c = props.contest;
31
28
  if (!c || c.status === 'cancelled') return [];
32
- const pos = STATUS_ORDER[c.status] ?? 0;
33
- const stepState = (idx: number): StepState => (idx < pos ? 'done' : idx === pos ? 'current' : 'upcoming');
34
- const steps: TimelineStep[] = [
35
- { label: 'Opens', date: fmt(c.startDate), state: stepState(0), icon: 'fa-flag' },
36
- { label: 'Submissions close', date: fmt(c.endDate), state: stepState(1), icon: 'fa-pen-to-square' },
37
- { label: 'Judging', date: fmt(c.judgingEndDate) ?? fmt(c.endDate), state: stepState(2), icon: 'fa-gavel' },
38
- { label: 'Results', date: null, state: stepState(3), icon: 'fa-ranking-star' },
39
- ];
40
- return steps;
29
+ const stages = normalizeStages(c);
30
+ const curId = currentStageId(c);
31
+ const curIdx = curId ? stages.findIndex((s) => s.id === curId) : -1;
32
+ return stages.map((s, i): TimelineStep => ({
33
+ label: s.name,
34
+ date: fmt(s.endsAt ?? s.startsAt ?? null),
35
+ state: curIdx < 0 ? 'upcoming' : i < curIdx ? 'done' : i === curIdx ? 'current' : 'upcoming',
36
+ icon: STAGE_KIND_ICON[s.kind] ?? 'fa-circle-dot',
37
+ }));
41
38
  });
42
39
 
43
40
  function statusClass(status: string): string {
@@ -0,0 +1,217 @@
1
+ <script setup lang="ts">
2
+ import type { ContestStage } from '@commonpub/schema';
3
+
4
+ // Phase B1 — define an arbitrary, ordered stage timeline for a contest. Empty ⇒
5
+ // the contest uses the synthesized standard flow (Submissions → Judging → Results),
6
+ // so this editor is opt-in. `kind` drives display + how the stage maps to the coarse
7
+ // status; `name`/dates are arbitrary. Used by both create.vue and edit.vue.
8
+
9
+ const stages = defineModel<ContestStage[]>({ required: true });
10
+ // Local name `currentId` avoids colliding with the auto-imported `currentStageId`
11
+ // util (the model name string stays `currentStageId` for the parent v-model).
12
+ const currentId = defineModel<string | null>('currentStageId', { default: null });
13
+
14
+ const props = defineProps<{
15
+ // Contest dates — used to seed the synthesized stages when the owner customizes.
16
+ startDate: string;
17
+ endDate: string;
18
+ judgingEndDate?: string | null;
19
+ }>();
20
+
21
+ const KINDS: ContestStage['kind'][] = ['submission', 'review', 'interim', 'results', 'event', 'custom'];
22
+
23
+ function newId(): string {
24
+ const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
25
+ return c?.randomUUID?.() ?? `s-${Date.now()}-${Math.round(Math.random() * 1e6)}`;
26
+ }
27
+
28
+ // datetime-local <-> ISO (mirrors the rest of the contest forms' convention).
29
+ function toLocal(iso?: string): string {
30
+ if (!iso) return '';
31
+ try { return new Date(iso).toISOString().slice(0, 16); } catch { return ''; }
32
+ }
33
+ function toIso(local: string): string | undefined {
34
+ if (!local) return undefined;
35
+ const d = new Date(local);
36
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString();
37
+ }
38
+
39
+ function commit(next: ContestStage[]): void {
40
+ stages.value = next;
41
+ }
42
+
43
+ function setField(i: number, patch: Partial<ContestStage>): void {
44
+ const next = stages.value.map((s, idx) => (idx === i ? { ...s, ...patch } : s));
45
+ commit(next);
46
+ }
47
+
48
+ function onDate(i: number, field: 'startsAt' | 'endsAt', e: Event): void {
49
+ const v = toIso((e.target as HTMLInputElement).value);
50
+ setField(i, { [field]: v } as Partial<ContestStage>);
51
+ }
52
+
53
+ function addStage(): void {
54
+ commit([...stages.value, { id: newId(), name: '', kind: 'custom' }]);
55
+ }
56
+
57
+ function duplicateStage(i: number): void {
58
+ const src = stages.value[i];
59
+ if (!src) return;
60
+ const copy: ContestStage = { ...src, id: newId(), name: `${src.name} (copy)`, core: false };
61
+ commit([...stages.value.slice(0, i + 1), copy, ...stages.value.slice(i + 1)]);
62
+ }
63
+
64
+ function removeStage(i: number): void {
65
+ const removed = stages.value[i];
66
+ commit(stages.value.filter((_, idx) => idx !== i));
67
+ if (removed && currentId.value === removed.id) currentId.value = null;
68
+ }
69
+
70
+ function move(i: number, dir: -1 | 1): void {
71
+ const j = i + dir;
72
+ if (j < 0 || j >= stages.value.length) return;
73
+ const next = [...stages.value];
74
+ [next[i], next[j]] = [next[j]!, next[i]!];
75
+ commit(next);
76
+ }
77
+
78
+ // Seed the editor with the standard three stages so the owner has a starting point.
79
+ function customize(): void {
80
+ const iso = (d?: string | null) => (d ? new Date(d).toISOString() : undefined);
81
+ commit([
82
+ { id: newId(), name: 'Submissions', kind: 'submission', startsAt: iso(props.startDate), endsAt: iso(props.endDate) },
83
+ { id: newId(), name: 'Judging', kind: 'review', endsAt: iso(props.judgingEndDate) ?? iso(props.endDate) },
84
+ { id: newId(), name: 'Results', kind: 'results' },
85
+ ]);
86
+ }
87
+
88
+ function resetToStandard(): void {
89
+ currentId.value = null;
90
+ commit([]);
91
+ }
92
+
93
+ const missingSubmission = computed(() => stages.value.length > 0 && !stages.value.some((s) => s.kind === 'submission'));
94
+ </script>
95
+
96
+ <template>
97
+ <div class="cpub-stages-editor">
98
+ <!-- Empty state: standard flow -->
99
+ <div v-if="!stages.length" class="cpub-stages-standard">
100
+ <p class="cpub-form-hint" style="margin: 0;">
101
+ This contest uses the <strong>standard flow</strong>: Submissions → Judging → Results, driven by
102
+ the schedule dates above. Customize only if you need extra rounds (e.g. a proposal round, a
103
+ Top-N selection, a build sprint, multiple judging rounds, or a showcase event).
104
+ </p>
105
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="customize">
106
+ <i class="fa-solid fa-diagram-project"></i> Customize stages
107
+ </button>
108
+ </div>
109
+
110
+ <template v-else>
111
+ <p v-if="missingSubmission" class="cpub-form-error" role="alert" style="margin: 0 0 10px;">
112
+ Add at least one <strong>Submissions</strong> stage, or reset to the standard flow.
113
+ </p>
114
+
115
+ <ol class="cpub-stage-list">
116
+ <li v-for="(stage, i) in stages" :key="stage.id" class="cpub-stage-row">
117
+ <div class="cpub-stage-row-head">
118
+ <span class="cpub-stage-num">{{ i + 1 }}</span>
119
+ <label class="cpub-stage-current" :title="currentId === stage.id ? 'This is the current stage' : 'Mark as the current stage'">
120
+ <input
121
+ type="radio"
122
+ name="cpub-current-stage"
123
+ :checked="currentId === stage.id"
124
+ @change="currentId = stage.id"
125
+ />
126
+ <span>Current</span>
127
+ </label>
128
+ <div class="cpub-stage-row-actions">
129
+ <button type="button" class="cpub-stage-iconbtn" :disabled="i === 0" aria-label="Move up" @click="move(i, -1)"><i class="fa-solid fa-arrow-up"></i></button>
130
+ <button type="button" class="cpub-stage-iconbtn" :disabled="i === stages.length - 1" aria-label="Move down" @click="move(i, 1)"><i class="fa-solid fa-arrow-down"></i></button>
131
+ <button type="button" class="cpub-stage-iconbtn" aria-label="Duplicate stage" @click="duplicateStage(i)"><i class="fa-solid fa-clone"></i></button>
132
+ <button type="button" class="cpub-stage-iconbtn cpub-stage-del" aria-label="Remove stage" @click="removeStage(i)"><i class="fa-solid fa-xmark"></i></button>
133
+ </div>
134
+ </div>
135
+
136
+ <div class="cpub-form-row">
137
+ <div class="cpub-form-field" style="flex: 2;">
138
+ <label class="cpub-form-label">Stage name</label>
139
+ <input
140
+ :value="stage.name"
141
+ type="text"
142
+ class="cpub-form-input"
143
+ placeholder="e.g. Proposals Open"
144
+ @input="setField(i, { name: ($event.target as HTMLInputElement).value })"
145
+ />
146
+ </div>
147
+ <div class="cpub-form-field" style="flex: 1;">
148
+ <label class="cpub-form-label">Type</label>
149
+ <select
150
+ :value="stage.kind"
151
+ class="cpub-form-input"
152
+ @change="setField(i, { kind: ($event.target as HTMLSelectElement).value as ContestStage['kind'] })"
153
+ >
154
+ <option v-for="k in KINDS" :key="k" :value="k">{{ STAGE_KIND_LABEL[k] }}</option>
155
+ </select>
156
+ </div>
157
+ </div>
158
+
159
+ <div class="cpub-form-row">
160
+ <div class="cpub-form-field">
161
+ <label class="cpub-form-label">Starts</label>
162
+ <input type="datetime-local" class="cpub-form-input" :value="toLocal(stage.startsAt)" @input="onDate(i, 'startsAt', $event)" />
163
+ </div>
164
+ <div class="cpub-form-field">
165
+ <label class="cpub-form-label">Ends (countdown target)</label>
166
+ <input type="datetime-local" class="cpub-form-input" :value="toLocal(stage.endsAt)" @input="onDate(i, 'endsAt', $event)" />
167
+ </div>
168
+ </div>
169
+
170
+ <div class="cpub-form-field">
171
+ <label class="cpub-form-label">Description (optional)</label>
172
+ <input
173
+ :value="stage.description ?? ''"
174
+ type="text"
175
+ class="cpub-form-input"
176
+ placeholder="What happens — or what to submit/refine — this stage"
177
+ @input="setField(i, { description: ($event.target as HTMLInputElement).value || undefined })"
178
+ />
179
+ </div>
180
+
181
+ <div v-if="stage.kind === 'event'" class="cpub-form-row">
182
+ <div class="cpub-form-field">
183
+ <label class="cpub-form-label">Location</label>
184
+ <input :value="stage.location ?? ''" type="text" class="cpub-form-input" placeholder="e.g. Washington, D.C." @input="setField(i, { location: ($event.target as HTMLInputElement).value || undefined })" />
185
+ </div>
186
+ <div class="cpub-form-field">
187
+ <label class="cpub-form-label">Link</label>
188
+ <input :value="stage.url ?? ''" type="url" class="cpub-form-input" placeholder="https://…" @input="setField(i, { url: ($event.target as HTMLInputElement).value || undefined })" />
189
+ </div>
190
+ </div>
191
+ </li>
192
+ </ol>
193
+
194
+ <div class="cpub-stage-toolbar">
195
+ <button type="button" class="cpub-btn cpub-btn-sm" @click="addStage"><i class="fa-solid fa-plus"></i> Add stage</button>
196
+ <button type="button" class="cpub-btn cpub-btn-sm cpub-stage-reset" @click="resetToStandard"><i class="fa-solid fa-rotate-left"></i> Reset to standard flow</button>
197
+ </div>
198
+ </template>
199
+ </div>
200
+ </template>
201
+
202
+ <style scoped>
203
+ .cpub-stages-standard { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
204
+ .cpub-stage-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
205
+ .cpub-stage-row { border: var(--border-width-default) solid var(--border); background: var(--surface2); padding: 12px; }
206
+ .cpub-stage-row-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
207
+ .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); }
208
+ .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; }
209
+ .cpub-stage-current input { width: 13px; height: 13px; }
210
+ .cpub-stage-row-actions { margin-left: auto; display: flex; gap: 4px; }
211
+ .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; }
212
+ .cpub-stage-iconbtn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
213
+ .cpub-stage-iconbtn:disabled { opacity: .4; cursor: not-allowed; }
214
+ .cpub-stage-del:hover { border-color: var(--red-border); color: var(--red); }
215
+ .cpub-stage-toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
216
+ .cpub-stage-reset { color: var(--text-faint); }
217
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -53,16 +53,16 @@
53
53
  "vue": "^3.4.0",
54
54
  "vue-router": "^4.3.0",
55
55
  "zod": "^4.3.6",
56
+ "@commonpub/editor": "0.7.11",
56
57
  "@commonpub/auth": "0.8.0",
57
58
  "@commonpub/config": "0.18.0",
58
59
  "@commonpub/docs": "0.6.3",
59
- "@commonpub/explainer": "0.7.15",
60
- "@commonpub/editor": "0.7.11",
61
- "@commonpub/learning": "0.5.2",
62
- "@commonpub/schema": "0.28.0",
63
60
  "@commonpub/protocol": "0.13.0",
61
+ "@commonpub/schema": "0.29.0",
62
+ "@commonpub/server": "2.76.0",
64
63
  "@commonpub/ui": "0.9.2",
65
- "@commonpub/server": "2.75.0"
64
+ "@commonpub/learning": "0.5.2",
65
+ "@commonpub/explainer": "0.7.15"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@testing-library/jest-dom": "^6.9.1",
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import type { ContestStage } from '@commonpub/schema';
3
+
2
4
  definePageMeta({ middleware: 'auth' });
3
5
 
4
6
  const route = useRoute();
@@ -55,6 +57,10 @@ const prizes = ref<Prize[]>([]);
55
57
  interface Criterion { label: string; weight: number | null; description: string }
56
58
  const criteria = ref<Criterion[]>([]);
57
59
 
60
+ // Phase B1 — explicit stage timeline (empty ⇒ standard synthesized flow).
61
+ const stages = ref<ContestStage[]>([]);
62
+ const currentStageIdRef = ref<string | null>(null);
63
+
58
64
  // Load current data
59
65
  watch(contest, (c) => {
60
66
  if (!c) return;
@@ -75,6 +81,8 @@ watch(contest, (c) => {
75
81
  visibility.value = (c.visibility as typeof visibility.value) ?? 'public';
76
82
  visibleToRoles.value = [...(c.visibleToRoles ?? [])];
77
83
  showPrizes.value = c.showPrizes !== false;
84
+ stages.value = Array.isArray(c.stages) ? [...c.stages] : [];
85
+ currentStageIdRef.value = c.currentStageId ?? null;
78
86
  prizesDescription.value = c.prizesDescription ?? '';
79
87
  prizes.value = (c.prizes ?? []).map((p: { place?: number; category?: string; title?: string; description?: string; value?: string }) => ({
80
88
  place: p.place ?? null,
@@ -166,6 +174,8 @@ async function handleSave(): Promise<void> {
166
174
  visibility: visibility.value,
167
175
  visibleToRoles: visibility.value === 'private' ? visibleToRoles.value : [],
168
176
  showPrizes: showPrizes.value,
177
+ stages: stages.value,
178
+ currentStageId: currentStageIdRef.value ?? undefined,
169
179
  prizesDescription: prizesDescription.value || undefined,
170
180
  prizes: prizeData,
171
181
  judgingCriteria: criteriaData,
@@ -201,30 +211,10 @@ async function handleDelete(): Promise<void> {
201
211
  }
202
212
  }
203
213
 
204
- // Client mirror of the server VALID_TRANSITIONS map bidirectional (go back a
205
- // stage, pause/resume, reopen). Kept in sync with server/src/contest/contest.ts.
206
- const VALID_TRANSITIONS: Record<string, string[]> = {
207
- draft: ['upcoming', 'active', 'cancelled'],
208
- upcoming: ['draft', 'active', 'cancelled'],
209
- active: ['upcoming', 'paused', 'judging', 'cancelled'],
210
- paused: ['active', 'upcoming', 'judging', 'cancelled'],
211
- judging: ['active', 'paused', 'completed', 'cancelled'],
212
- completed: ['judging'],
213
- cancelled: ['draft', 'upcoming'],
214
- };
215
- const STATUS_ACTION: Record<string, { label: string; icon: string; tone?: 'go' | 'warn' | 'danger' }> = {
216
- draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
217
- upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
218
- active: { label: 'Activate', icon: 'fa-play', tone: 'go' },
219
- paused: { label: 'Pause', icon: 'fa-pause', tone: 'warn' },
220
- judging: { label: 'Begin Judging', icon: 'fa-gavel' },
221
- completed: { label: 'Complete & Publish', icon: 'fa-flag-checkered', tone: 'go' },
222
- cancelled: { label: 'Cancel', icon: 'fa-ban', tone: 'danger' },
223
- };
224
- const availableTransitions = computed<string[]>(() => VALID_TRANSITIONS[contest.value?.status ?? 'upcoming'] ?? []);
225
- function statusAction(s: string): { label: string; icon: string; tone?: string } {
226
- return STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
227
- }
214
+ // Bidirectional lifecycle controls the valid-transition map + button metadata
215
+ // live in utils/contestTransitions.ts (shared with ContestHero).
216
+ const availableTransitions = computed<string[]>(() => contestTransitionsFrom(contest.value?.status));
217
+ const statusAction = contestStatusAction;
228
218
 
229
219
  async function transitionStatus(newStatus: string): Promise<void> {
230
220
  // Only the consequential transitions confirm; reversible nudges (pause/resume,
@@ -307,6 +297,18 @@ async function transitionStatus(newStatus: string): Promise<void> {
307
297
  <p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
308
298
  </section>
309
299
 
300
+ <section class="cpub-form-section">
301
+ <h2 class="cpub-form-section-title">Stages</h2>
302
+ <p class="cpub-form-hint">Optional. The standard flow (Submissions → Judging → Results) is derived from the schedule above. Add custom stages for multi-round contests — proposal rounds, a Top-N selection, a build sprint, multiple judging rounds, or a showcase event.</p>
303
+ <ContestStagesEditor
304
+ v-model="stages"
305
+ v-model:current-stage-id="currentStageIdRef"
306
+ :start-date="startDate"
307
+ :end-date="endDate"
308
+ :judging-end-date="judgingEndDate"
309
+ />
310
+ </section>
311
+
310
312
  <section class="cpub-form-section">
311
313
  <h2 class="cpub-form-section-title">Entries</h2>
312
314
  <div class="cpub-form-field">
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import type { ContestStage } from '@commonpub/schema';
3
+
2
4
  definePageMeta({ middleware: 'auth' });
3
5
 
4
6
  useSeoMeta({ title: `Create Contest — ${useSiteName()}` });
@@ -57,6 +59,10 @@ interface Prize {
57
59
 
58
60
  const showPrizes = ref(true);
59
61
  const prizesDescription = ref('');
62
+
63
+ // Phase B1 — explicit stage timeline (empty ⇒ standard synthesized flow).
64
+ const stages = ref<ContestStage[]>([]);
65
+ const currentStageIdRef = ref<string | null>(null);
60
66
  // Prizes are entirely optional — start empty so a contest has NO prizes unless
61
67
  // the operator explicitly adds them (the old 3 pre-filled rows forced prizes
62
68
  // onto every contest, since their non-empty titles survived the submit filter).
@@ -116,6 +122,8 @@ async function handleCreate(): Promise<void> {
116
122
  eligibleContentTypes: eligibleContentTypes.value.length ? eligibleContentTypes.value : undefined,
117
123
  maxEntriesPerUser: maxEntriesPerUser.value && maxEntriesPerUser.value > 0 ? maxEntriesPerUser.value : undefined,
118
124
  showPrizes: showPrizes.value,
125
+ stages: stages.value.length ? stages.value : undefined,
126
+ currentStageId: currentStageIdRef.value ?? undefined,
119
127
  prizesDescription: prizesDescription.value || undefined,
120
128
  prizes: prizes.value
121
129
  .filter(p => p.title.trim() || p.description.trim() || p.category.trim() || (typeof p.place === 'number' && p.place > 0))
@@ -215,6 +223,19 @@ function prizeLabel(prize: Prize): string {
215
223
  <p v-if="dateError" class="cpub-form-error" role="alert">{{ dateError }}</p>
216
224
  </section>
217
225
 
226
+ <!-- Stages -->
227
+ <section class="cpub-form-section">
228
+ <h2 class="cpub-form-section-title">Stages <span style="color: var(--text-faint); font-weight: 400; font-size: 0.75em; font-family: var(--font-mono);">— optional</span></h2>
229
+ <p class="cpub-form-hint">The standard flow (Submissions → Judging → Results) is derived from the schedule above. Add custom stages for multi-round contests — proposal rounds, a Top-N selection, a build sprint, multiple judging rounds, or a showcase event.</p>
230
+ <ContestStagesEditor
231
+ v-model="stages"
232
+ v-model:current-stage-id="currentStageIdRef"
233
+ :start-date="startDate"
234
+ :end-date="endDate"
235
+ :judging-end-date="judgingEndDate"
236
+ />
237
+ </section>
238
+
218
239
  <!-- Visibility & Access -->
219
240
  <section class="cpub-form-section">
220
241
  <h2 class="cpub-form-section-title">Visibility &amp; Access</h2>
@@ -0,0 +1,70 @@
1
+ import type { ContestStage } from '@commonpub/schema';
2
+
3
+ // Client mirror of the pure stage helpers in @commonpub/server `contest.ts`
4
+ // (synthesizeStages / normalizeStages / currentStage). Deliberately duplicated —
5
+ // importing the server package into the browser bundle would pull in DB drivers.
6
+ // If the server derivation changes, change this in lockstep (same contract as the
7
+ // VALID_TRANSITIONS mirror in ContestHero/edit.vue).
8
+
9
+ export interface StageSource {
10
+ status: string;
11
+ startDate: string;
12
+ endDate: string;
13
+ judgingEndDate: string | null;
14
+ stages?: ContestStage[] | null;
15
+ currentStageId?: string | null;
16
+ }
17
+
18
+ const iso = (d: string | null | undefined): string | undefined => (d ? new Date(d).toISOString() : undefined);
19
+
20
+ /** Classic Submissions → Judging → Results, synthesized from status + dates. */
21
+ export function synthesizeStages(c: StageSource): ContestStage[] {
22
+ return [
23
+ { id: 'core-submission', name: 'Submissions', kind: 'submission', core: true, startsAt: iso(c.startDate), endsAt: iso(c.endDate) },
24
+ { id: 'core-review', name: 'Judging', kind: 'review', core: true, endsAt: iso(c.judgingEndDate) ?? iso(c.endDate) },
25
+ { id: 'core-results', name: 'Results', kind: 'results', core: true },
26
+ ];
27
+ }
28
+
29
+ /** Explicit stages if defined, else the synthesized classic flow (the default). */
30
+ export function normalizeStages(c: StageSource): ContestStage[] {
31
+ return c.stages && c.stages.length > 0 ? c.stages : synthesizeStages(c);
32
+ }
33
+
34
+ /** Id of the stage that is "now" — the resolvable `currentStageId`, else derived
35
+ * from `status`. Null while draft/cancelled (nothing running). */
36
+ export function currentStageId(c: StageSource): string | null {
37
+ const stages = normalizeStages(c);
38
+ if (c.currentStageId && stages.some((s) => s.id === c.currentStageId)) return c.currentStageId;
39
+ switch (c.status) {
40
+ case 'draft':
41
+ case 'cancelled':
42
+ return null;
43
+ case 'completed':
44
+ return (stages.find((s) => s.kind === 'results') ?? stages[stages.length - 1])?.id ?? null;
45
+ case 'judging':
46
+ return stages.find((s) => s.kind === 'review')?.id ?? null;
47
+ default: // upcoming | active | paused
48
+ return (stages.find((s) => s.kind === 'submission') ?? stages[0])?.id ?? null;
49
+ }
50
+ }
51
+
52
+ /** FontAwesome icon (no `fa-solid` prefix) for each stage kind. */
53
+ export const STAGE_KIND_ICON: Record<ContestStage['kind'], string> = {
54
+ submission: 'fa-pen-to-square',
55
+ review: 'fa-gavel',
56
+ interim: 'fa-screwdriver-wrench',
57
+ results: 'fa-ranking-star',
58
+ event: 'fa-flag-checkered',
59
+ custom: 'fa-circle-dot',
60
+ };
61
+
62
+ /** Human label for each stage kind (for the editor dropdown). */
63
+ export const STAGE_KIND_LABEL: Record<ContestStage['kind'], string> = {
64
+ submission: 'Submissions',
65
+ review: 'Judging / Review',
66
+ interim: 'Working period (sprint)',
67
+ results: 'Results',
68
+ event: 'Event / Showcase',
69
+ custom: 'Custom milestone',
70
+ };
@@ -0,0 +1,34 @@
1
+ // Client mirror of the server VALID_TRANSITIONS map (@commonpub/server contest.ts).
2
+ // Single source of truth for the contest lifecycle controls in ContestHero and
3
+ // the contest edit page — keeps the offered buttons in sync with what the API
4
+ // accepts. If the server map changes, change this in lockstep (a client-only or
5
+ // server-only edit silently desyncs: the UI offers a button the API rejects).
6
+
7
+ export const CONTEST_VALID_TRANSITIONS: Record<string, string[]> = {
8
+ draft: ['upcoming', 'active', 'cancelled'],
9
+ upcoming: ['draft', 'active', 'cancelled'],
10
+ active: ['upcoming', 'paused', 'judging', 'cancelled'],
11
+ paused: ['active', 'upcoming', 'judging', 'cancelled'],
12
+ judging: ['active', 'paused', 'completed', 'cancelled'],
13
+ completed: ['judging'],
14
+ cancelled: ['draft', 'upcoming'],
15
+ };
16
+
17
+ /** Display metadata for a transition target. `tone` styles the button (go/warn/danger). */
18
+ export const CONTEST_STATUS_ACTION: Record<string, { label: string; icon: string; tone?: 'go' | 'warn' | 'danger' }> = {
19
+ draft: { label: 'Move to Draft', icon: 'fa-pen-ruler' },
20
+ upcoming: { label: 'Set Upcoming', icon: 'fa-clock' },
21
+ active: { label: 'Activate', icon: 'fa-play', tone: 'go' },
22
+ paused: { label: 'Pause', icon: 'fa-pause', tone: 'warn' },
23
+ judging: { label: 'Start Judging', icon: 'fa-gavel' },
24
+ completed: { label: 'Complete & Publish', icon: 'fa-flag-checkered', tone: 'go' },
25
+ cancelled: { label: 'Cancel', icon: 'fa-ban', tone: 'danger' },
26
+ };
27
+
28
+ export function contestTransitionsFrom(status: string | undefined): string[] {
29
+ return CONTEST_VALID_TRANSITIONS[status ?? 'upcoming'] ?? [];
30
+ }
31
+
32
+ export function contestStatusAction(s: string): { label: string; icon: string; tone?: string } {
33
+ return CONTEST_STATUS_ACTION[s] ?? { label: s, icon: 'fa-circle' };
34
+ }