@appshoteditor/shot-dsl 0.2.0 → 0.4.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/README.md +206 -7
- package/package.json +5 -2
- package/src/color.ts +76 -0
- package/src/compose.ts +1272 -107
- package/src/frames.ts +132 -89
- package/src/index.ts +3 -0
- package/src/layout-system.ts +302 -0
- package/src/types.ts +37 -1
- package/src/validate.ts +200 -1
- package/src/variants.ts +125 -0
package/src/validate.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LayerJSON, ScreenLayersJSON, Template } from './types';
|
|
2
2
|
import { CURRENT_SCHEMA_VERSION } from './types';
|
|
3
|
+
import { getDeviceFrame } from './device-frames';
|
|
3
4
|
|
|
4
5
|
const LAYER_TYPES = ['background', 'text', 'image', 'device', 'shape'];
|
|
5
6
|
|
|
@@ -83,6 +84,194 @@ export function generateLayerId(): string {
|
|
|
83
84
|
return `layer-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* The only screenshot URL form a device layer's `fabricData.screenshot.src` may carry: a
|
|
89
|
+
* same-origin, root-relative reference to the user's uploaded asset (`/api/screenshots/<id>/raw`,
|
|
90
|
+
* which the Worker serves owner-only from private R2). Rejects external, protocol-relative,
|
|
91
|
+
* `data:`/`javascript:` and path-traversal URLs, so a handoff can't make the editor fetch
|
|
92
|
+
* arbitrary third-party images.
|
|
93
|
+
*/
|
|
94
|
+
export const UPLOADED_SCREENSHOT_SRC = /^\/api\/screenshots\/[A-Za-z0-9_-]{1,128}\/raw$/;
|
|
95
|
+
|
|
96
|
+
export function isUploadedScreenshotSrc(src: unknown): src is string {
|
|
97
|
+
return typeof src === 'string' && UPLOADED_SCREENSHOT_SRC.test(src);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A device layer's own `fabricData.src` — the frame PNG/WebP the editor serves from `static/devices/`
|
|
102
|
+
* (see `DeviceFrame.frameAsset`; the built-in templates still reference legacy `.png` names).
|
|
103
|
+
* Single path segment, no dots besides the extension, so no traversal.
|
|
104
|
+
*/
|
|
105
|
+
export const DEVICE_FRAME_SRC = /^\/devices\/[a-z0-9][a-z0-9-]*\.(?:webp|png)$/;
|
|
106
|
+
|
|
107
|
+
export function isDeviceFrameSrc(src: unknown): src is string {
|
|
108
|
+
return typeof src === 'string' && DEVICE_FRAME_SRC.test(src);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const SCREENSHOT_ROTATIONS = [0, 90, 180, 270];
|
|
112
|
+
const SCREENSHOT_KEYS = new Set(['src', 'width', 'height', 'rotation']);
|
|
113
|
+
|
|
114
|
+
/** Problems with a device layer's `fabricData.screenshot` (see `DeviceScreenshotJSON`); empty ⇒ valid. */
|
|
115
|
+
export function validateDeviceScreenshot(value: unknown, at = 'screenshot'): string[] {
|
|
116
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return [`${at} must be an object`];
|
|
117
|
+
const errors: string[] = [];
|
|
118
|
+
const shot = value as Record<string, unknown>;
|
|
119
|
+
for (const key of Object.keys(shot)) {
|
|
120
|
+
if (!SCREENSHOT_KEYS.has(key)) errors.push(`${at}.${key} is not allowed`);
|
|
121
|
+
}
|
|
122
|
+
if (!isUploadedScreenshotSrc(shot.src)) {
|
|
123
|
+
errors.push(`${at}.src must be an uploaded screenshot URL (/api/screenshots/<id>/raw)`);
|
|
124
|
+
}
|
|
125
|
+
for (const dim of ['width', 'height'] as const) {
|
|
126
|
+
const n = shot[dim];
|
|
127
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) {
|
|
128
|
+
errors.push(`${at}.${dim} must be a positive number`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (shot.rotation !== undefined && !SCREENSHOT_ROTATIONS.includes(shot.rotation as number)) {
|
|
132
|
+
errors.push(`${at}.rotation must be one of 0, 90, 180, 270`);
|
|
133
|
+
}
|
|
134
|
+
return errors;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Editor-internal Fabric properties that drive loading/placement at runtime. Never legitimate in a
|
|
139
|
+
* handoff: e.g. `pendingScreenshot` makes the editor (re)load an image URL as a frame's screenshot,
|
|
140
|
+
* which would bypass the `screenshot.src` rules below.
|
|
141
|
+
*/
|
|
142
|
+
export const EDITOR_INTERNAL_PROPS = [
|
|
143
|
+
'pendingScreenshot',
|
|
144
|
+
'_lastFrameScaleX',
|
|
145
|
+
'_lastFrameScaleY',
|
|
146
|
+
'_userScale',
|
|
147
|
+
'_userOffsetX',
|
|
148
|
+
'_userOffsetY'
|
|
149
|
+
] as const;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Keys that JSON.parse keeps as OWN properties but that change meaning once copied/enlivened
|
|
153
|
+
* (`__proto__` smuggles properties past `in`/dot checks; `constructor` breaks Fabric's enliven).
|
|
154
|
+
*/
|
|
155
|
+
export const FORBIDDEN_KEYS = ['__proto__', 'constructor', 'prototype'] as const;
|
|
156
|
+
|
|
157
|
+
/** Problems for every forbidden key anywhere inside `value` (own keys only, depth-capped). */
|
|
158
|
+
export function findForbiddenKeys(value: unknown, at: string, depth = 0): string[] {
|
|
159
|
+
if (depth > 32 || !value || typeof value !== 'object') return [];
|
|
160
|
+
const errors: string[] = [];
|
|
161
|
+
const entries = Array.isArray(value)
|
|
162
|
+
? value.map((v, k) => [`[${k}]`, v] as const)
|
|
163
|
+
: Object.keys(value).map((k) => [`.${k}`, (value as Record<string, unknown>)[k]] as const);
|
|
164
|
+
for (const [suffix, v] of entries) {
|
|
165
|
+
const key = suffix.startsWith('.') ? suffix.slice(1) : null;
|
|
166
|
+
if (key !== null && (FORBIDDEN_KEYS as readonly string[]).includes(key)) {
|
|
167
|
+
errors.push(`${at}${suffix} is not allowed`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
errors.push(...findForbiddenKeys(v, `${at}${suffix}`, depth + 1));
|
|
171
|
+
}
|
|
172
|
+
return errors;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Any `src` / `source` (image layers, pattern fills, group children, clip paths…) must be one of these. */
|
|
176
|
+
function isAllowedImageRef(value: unknown): boolean {
|
|
177
|
+
return isUploadedScreenshotSrc(value) || isDeviceFrameSrc(value);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Collect problems with every `src`/`source` value anywhere inside `value`. */
|
|
181
|
+
function checkImageRefs(value: unknown, at: string, errors: string[], depth = 0): void {
|
|
182
|
+
if (depth > 32) {
|
|
183
|
+
errors.push(`${at} is nested too deeply`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (Array.isArray(value)) {
|
|
187
|
+
value.forEach((v, k) => checkImageRefs(v, `${at}[${k}]`, errors, depth + 1));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!value || typeof value !== 'object') return;
|
|
191
|
+
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
|
192
|
+
if ((key === 'src' || key === 'source') && !isAllowedImageRef(v)) {
|
|
193
|
+
errors.push(
|
|
194
|
+
`${at}.${key} must be an uploaded screenshot (/api/screenshots/<id>/raw) or a device frame asset (/devices/<name>.webp|png)`
|
|
195
|
+
);
|
|
196
|
+
} else if (v && typeof v === 'object') {
|
|
197
|
+
checkImageRefs(v, `${at}.${key}`, errors, depth + 1);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Per-layer fabricData rules for handoffs (see validateTemplate). */
|
|
203
|
+
function validateLayerFabricData(
|
|
204
|
+
layer: LayerJSON,
|
|
205
|
+
fd: Record<string, unknown>,
|
|
206
|
+
at: string,
|
|
207
|
+
frameIds: Set<string>
|
|
208
|
+
): string[] {
|
|
209
|
+
const errors: string[] = [];
|
|
210
|
+
const fdAt = `${at}.fabricData`;
|
|
211
|
+
|
|
212
|
+
for (const prop of EDITOR_INTERNAL_PROPS) {
|
|
213
|
+
if (prop in fd) errors.push(`${fdAt}.${prop} is editor-internal and not allowed`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Frame role/metadata only on device layers; screenshot role only on a legacy image layer.
|
|
217
|
+
const isLegacyScreenshot = fd.layerRole === 'screenshot';
|
|
218
|
+
if (layer.type === 'device') {
|
|
219
|
+
if (fd.layerRole !== undefined && fd.layerRole !== 'frame') {
|
|
220
|
+
errors.push(`${fdAt}.layerRole must be "frame" on a device layer`);
|
|
221
|
+
}
|
|
222
|
+
if (typeof fd.deviceId !== 'string' || !getDeviceFrame(fd.deviceId)) {
|
|
223
|
+
errors.push(`${fdAt}.deviceId must be a known device id`);
|
|
224
|
+
}
|
|
225
|
+
if (!isDeviceFrameSrc(fd.src)) {
|
|
226
|
+
errors.push(`${fdAt}.src must be a device frame asset (/devices/<name>.webp|png)`);
|
|
227
|
+
}
|
|
228
|
+
if (typeof fd.deviceFrameId === 'string' && fd.deviceFrameId) {
|
|
229
|
+
if (frameIds.has(fd.deviceFrameId)) {
|
|
230
|
+
errors.push(`${fdAt}.deviceFrameId "${fd.deviceFrameId}" is duplicated in this screen`);
|
|
231
|
+
}
|
|
232
|
+
frameIds.add(fd.deviceFrameId);
|
|
233
|
+
}
|
|
234
|
+
} else if (isLegacyScreenshot) {
|
|
235
|
+
// Legacy (≤0.2.0) separate screenshot layer: still accepted, but only in its real shape and
|
|
236
|
+
// held to the same uploaded-asset src rule as the 0.3.0 property.
|
|
237
|
+
if (layer.type !== 'image' || typeof fd.type !== 'string' || fd.type.toLowerCase() !== 'image') {
|
|
238
|
+
errors.push(`${fdAt}.layerRole "screenshot" is only allowed on image layers`);
|
|
239
|
+
}
|
|
240
|
+
if (!isUploadedScreenshotSrc(fd.src)) {
|
|
241
|
+
errors.push(`${fdAt}.src must be an uploaded screenshot URL (/api/screenshots/<id>/raw)`);
|
|
242
|
+
}
|
|
243
|
+
if (fd.deviceId !== undefined && (typeof fd.deviceId !== 'string' || !getDeviceFrame(fd.deviceId))) {
|
|
244
|
+
errors.push(`${fdAt}.deviceId must be a known device id`);
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
if (fd.layerRole !== undefined) errors.push(`${fdAt}.layerRole is only allowed on device / legacy screenshot layers`);
|
|
248
|
+
for (const key of ['deviceFrameId', 'deviceId', 'deviceScale']) {
|
|
249
|
+
if (fd[key] !== undefined) errors.push(`${fdAt}.${key} is only allowed on device / legacy screenshot layers`);
|
|
250
|
+
}
|
|
251
|
+
if (fd.layerType === 'deviceFrame') errors.push(`${fdAt}.layerType "deviceFrame" is only allowed on device layers`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Device-owned screenshot (0.3.0+).
|
|
255
|
+
if (fd.screenshot !== undefined) {
|
|
256
|
+
if (layer.type !== 'device') errors.push(`${fdAt}.screenshot is only allowed on device layers`);
|
|
257
|
+
errors.push(...validateDeviceScreenshot(fd.screenshot, `${fdAt}.screenshot`));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Every other image reference (image layers, pattern fills, nested objects): same-origin
|
|
261
|
+
// uploaded screenshots or device frame assets only. (Top-level device/screenshot srcs were
|
|
262
|
+
// checked above with their stricter rule; skip them here to avoid duplicate messages.)
|
|
263
|
+
const { src: _src, screenshot: _screenshot, ...rest } = fd;
|
|
264
|
+
void _screenshot;
|
|
265
|
+
if (layer.type !== 'device' && !isLegacyScreenshot && _src !== undefined && !isAllowedImageRef(_src)) {
|
|
266
|
+
errors.push(
|
|
267
|
+
`${fdAt}.src must be an uploaded screenshot (/api/screenshots/<id>/raw) or a device frame asset (/devices/<name>.webp|png)`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
checkImageRefs(rest, fdAt, errors);
|
|
271
|
+
|
|
272
|
+
return errors;
|
|
273
|
+
}
|
|
274
|
+
|
|
86
275
|
export interface ValidationResult {
|
|
87
276
|
valid: boolean;
|
|
88
277
|
errors: string[];
|
|
@@ -115,8 +304,18 @@ export function validateTemplate(data: unknown): ValidationResult {
|
|
|
115
304
|
if (s.schemaVersion > CURRENT_SCHEMA_VERSION) {
|
|
116
305
|
errors.push(`screens[${i}] has unsupported schemaVersion ${s.schemaVersion}`);
|
|
117
306
|
}
|
|
307
|
+
const layerIds = new Set<string>();
|
|
308
|
+
const frameIds = new Set<string>();
|
|
118
309
|
s.layers.forEach((layer, j) => {
|
|
119
|
-
|
|
310
|
+
const at = `screens[${i}].layers[${j}]`;
|
|
311
|
+
if (!isValidLayerJSON(layer)) errors.push(`${at} is invalid`);
|
|
312
|
+
errors.push(...findForbiddenKeys(layer, at));
|
|
313
|
+
if (layerIds.has(layer.id)) errors.push(`${at}.id "${layer.id}" is duplicated in this screen`);
|
|
314
|
+
layerIds.add(layer.id);
|
|
315
|
+
|
|
316
|
+
const fd = layer.fabricData as Record<string, unknown> | null;
|
|
317
|
+
if (!fd || typeof fd !== 'object') return;
|
|
318
|
+
errors.push(...validateLayerFabricData(layer, fd, at, frameIds));
|
|
120
319
|
});
|
|
121
320
|
});
|
|
122
321
|
}
|
package/src/variants.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { canvasDimsForDevice, type ComposePlan, type ComposeScreenPlan, type ComposeStyle } from './compose';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Three DISTINCT concepts from one plan — Product Page Optimization test candidates, not tweaks:
|
|
5
|
+
* - **A Framed**: device mockups, straight, one shared layout.
|
|
6
|
+
* - **B Frameless**: bare rounded screenshots; screens with a `crop` (or a tight `focus` band) become
|
|
7
|
+
* magnified zoom cards of that UI.
|
|
8
|
+
* - **C Panorama**: adjacent pairs share one continuous background + seam orbs; only the tilted hero
|
|
9
|
+
* (screen 1) straddles its seam — one crossing per set, rotation stays an accent.
|
|
10
|
+
*
|
|
11
|
+
* KEPT from the input (every concept): name (+ suffix), canvas size, copy (headline / subheadline /
|
|
12
|
+
* badge), colours (headlineColor / subheadlineColor), `background`, `deviceId`, `screenshot`,
|
|
13
|
+
* `focus`, `crop`, and the style's `palette`, `font` and `bleed` preference (default `auto`).
|
|
14
|
+
*
|
|
15
|
+
* OVERRIDDEN (the concept decides these):
|
|
16
|
+
* - every screen's `layout` → `text-top`: screens only share one device scale + baseline when they
|
|
17
|
+
* share a layout, and that shared system is what makes a set look designed;
|
|
18
|
+
* - per-screen `presentation` / `tilt` are dropped; `style.presentation` is `device` (A, C) or
|
|
19
|
+
* `frameless` (B, with `zoom` on screens that have a `crop` or a focus band ≤ 45% tall);
|
|
20
|
+
* - A and B are straight and without panorama (`tilt`, `tiltScreens`, `panorama` removed);
|
|
21
|
+
* - C tilts screen 1 only (`tiltScreens: [0]`, the input's non-zero `style.tilt` or 8°) and uses the
|
|
22
|
+
* input's `panorama.spans` if it has them (else adjacent pairs of the same canvas size); its
|
|
23
|
+
* `straddle` is the input's if set, else the hero only; `decoration` defaults to `orbs`.
|
|
24
|
+
*/
|
|
25
|
+
export type VariantKey = 'A' | 'B' | 'C';
|
|
26
|
+
|
|
27
|
+
export interface ComposeVariant {
|
|
28
|
+
key: VariantKey;
|
|
29
|
+
label: string;
|
|
30
|
+
plan: ComposePlan;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const VARIANT_LABELS: Record<VariantKey, string> = { A: 'Framed', B: 'Frameless', C: 'Panorama' };
|
|
34
|
+
|
|
35
|
+
/** Tilt (degrees) the panorama concept gives its hero screen. */
|
|
36
|
+
export const VARIANT_HERO_TILT = 8;
|
|
37
|
+
/** A focus band at most this tall (fraction of the screenshot) is worth a zoom card in concept B. */
|
|
38
|
+
export const VARIANT_ZOOM_MAX_FOCUS = 0.45;
|
|
39
|
+
|
|
40
|
+
const suffix = (name: string, key: VariantKey) => `${name} — ${key} ${VARIANT_LABELS[key]}`;
|
|
41
|
+
|
|
42
|
+
/** Copy a screen without the per-screen style knobs a concept decides (layout/presentation/tilt). */
|
|
43
|
+
function baseScreen(screen: ComposeScreenPlan): ComposeScreenPlan {
|
|
44
|
+
const copy: ComposeScreenPlan = JSON.parse(JSON.stringify(screen));
|
|
45
|
+
delete copy.layout;
|
|
46
|
+
delete copy.presentation;
|
|
47
|
+
delete copy.tilt;
|
|
48
|
+
return { ...copy, layout: 'text-top' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function baseStyle(style: ComposeStyle | undefined): ComposeStyle {
|
|
52
|
+
const out: ComposeStyle = { bleed: style?.bleed ?? 'auto' };
|
|
53
|
+
if (style?.palette) out.palette = JSON.parse(JSON.stringify(style.palette));
|
|
54
|
+
if (style?.font) out.font = style.font;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Adjacent pairs [0,1], [2,3], … of screens that share a canvas size (a lone last screen stays single). */
|
|
59
|
+
export function panoramaPairs(plan: ComposePlan): number[][] {
|
|
60
|
+
const explicit = plan.canvasWidth != null || plan.canvasHeight != null;
|
|
61
|
+
const key = (s: ComposeScreenPlan) => {
|
|
62
|
+
if (explicit) return 'explicit';
|
|
63
|
+
const d = canvasDimsForDevice(s.deviceId);
|
|
64
|
+
return `${d.width}x${d.height}`;
|
|
65
|
+
};
|
|
66
|
+
const spans: number[][] = [];
|
|
67
|
+
for (let i = 0; i + 1 < plan.screens.length; ) {
|
|
68
|
+
if (key(plan.screens[i]) === key(plan.screens[i + 1])) {
|
|
69
|
+
spans.push([i, i + 1]);
|
|
70
|
+
i += 2;
|
|
71
|
+
} else {
|
|
72
|
+
i += 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return spans;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function makeVariants(plan: ComposePlan): ComposeVariant[] {
|
|
79
|
+
const common = { canvasWidth: plan.canvasWidth, canvasHeight: plan.canvasHeight };
|
|
80
|
+
const strip = <T extends object>(o: T): T => JSON.parse(JSON.stringify(o)); // drops undefined keys
|
|
81
|
+
|
|
82
|
+
const a: ComposePlan = strip({
|
|
83
|
+
...common,
|
|
84
|
+
name: suffix(plan.name, 'A'),
|
|
85
|
+
style: { ...baseStyle(plan.style), presentation: 'device' },
|
|
86
|
+
screens: plan.screens.map(baseScreen)
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const b: ComposePlan = strip({
|
|
90
|
+
...common,
|
|
91
|
+
name: suffix(plan.name, 'B'),
|
|
92
|
+
style: { ...baseStyle(plan.style), presentation: 'frameless' },
|
|
93
|
+
screens: plan.screens.map((screen) => {
|
|
94
|
+
const s = baseScreen(screen);
|
|
95
|
+
const f = screen.focus;
|
|
96
|
+
const tight = !!f && Math.abs(f.bottom - f.top) <= VARIANT_ZOOM_MAX_FOCUS;
|
|
97
|
+
if (screen.crop || tight) s.presentation = 'zoom';
|
|
98
|
+
return s;
|
|
99
|
+
})
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const pano = plan.style?.panorama;
|
|
103
|
+
const spans = pano?.spans?.length ? JSON.parse(JSON.stringify(pano.spans)) : panoramaPairs(plan);
|
|
104
|
+
const heroStraddle = spans.some((span: number[]) => span[0] === 0) ? [0] : [];
|
|
105
|
+
const c: ComposePlan = strip({
|
|
106
|
+
...common,
|
|
107
|
+
name: suffix(plan.name, 'C'),
|
|
108
|
+
style: {
|
|
109
|
+
...baseStyle(plan.style),
|
|
110
|
+
presentation: 'device',
|
|
111
|
+
tilt: plan.style?.tilt ? plan.style.tilt : VARIANT_HERO_TILT,
|
|
112
|
+
tiltScreens: [0],
|
|
113
|
+
// Only the hero straddles by default (≤ 1 seam crossing per set); other spans keep the
|
|
114
|
+
// continuous background + orbs with centred, straight devices.
|
|
115
|
+
panorama: { spans, straddle: pano?.straddle ?? heroStraddle, decoration: pano?.decoration ?? 'orbs' }
|
|
116
|
+
},
|
|
117
|
+
screens: plan.screens.map(baseScreen)
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return [
|
|
121
|
+
{ key: 'A', label: VARIANT_LABELS.A, plan: a },
|
|
122
|
+
{ key: 'B', label: VARIANT_LABELS.B, plan: b },
|
|
123
|
+
{ key: 'C', label: VARIANT_LABELS.C, plan: c }
|
|
124
|
+
];
|
|
125
|
+
}
|