@dytsou/shorten-url 2.4.4 → 2.4.5
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 +88 -50
- package/config/config.example.js +137 -0
- package/config/config.js +19 -0
- package/docs/about.md +360 -0
- package/docs/api/openapi.yaml +441 -81
- package/docs/flagship.md +58 -0
- package/docs/plans/2026-09-06-001-refactor-worker-hosted-react-assets-plan.md +308 -0
- package/frontend/about-template.html +124 -0
- package/frontend/index.html +17 -0
- package/frontend/package.json +20 -0
- package/frontend/pnpm-lock.yaml +1129 -0
- package/frontend/src/App.jsx +1264 -0
- package/frontend/src/api.js +272 -0
- package/frontend/src/local-flag-mock.js +55 -0
- package/frontend/src/main.jsx +4 -0
- package/frontend/src/styles/app.css +2253 -0
- package/frontend/static/favicon.svg +5 -0
- package/frontend/vite.config.js +34 -0
- package/package.json +6 -4
- package/src/lib/access.js +241 -1
- package/src/lib/assets.js +58 -0
- package/src/lib/endpoints.js +33 -8
- package/src/lib/feature-toggle.js +464 -0
- package/src/lib/flag-management.js +137 -0
- package/src/lib/flag-routes.js +155 -0
- package/src/lib/flagship.js +269 -0
- package/src/lib/observability.js +34 -0
- package/src/lib/responses.js +39 -3
- package/src/lib/runtime-config.js +90 -0
- package/src/lib/safety.js +15 -0
- package/src/lib/shortener.js +2 -2
- package/src/lib/worker-handler.js +122 -0
- package/src/worker.js +3 -66
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
createFrontendConfig,
|
|
4
|
+
createShorteningFlag,
|
|
5
|
+
errorMessage,
|
|
6
|
+
loadSettings,
|
|
7
|
+
publishShorteningFlag,
|
|
8
|
+
shortenUrl,
|
|
9
|
+
updateShorteningFlag,
|
|
10
|
+
} from "./api.js";
|
|
11
|
+
import { loadLocalFlagMock, publishLocalFlagMock, saveLocalFlagMock } from "./local-flag-mock.js";
|
|
12
|
+
import "./styles/app.css";
|
|
13
|
+
|
|
14
|
+
const FLAG_KEY = "shorten-routing";
|
|
15
|
+
let nextEditorItemId = 0;
|
|
16
|
+
const FLAGSHIP_CONTEXT_FIELDS = [
|
|
17
|
+
{ key: "country", label: "Country code", example: "US or CA" },
|
|
18
|
+
{ key: "regionCode", label: "Region code", example: "TX" },
|
|
19
|
+
{ key: "continent", label: "Continent", example: "NA" },
|
|
20
|
+
{ key: "timezone", label: "Time zone", example: "America/Chicago" },
|
|
21
|
+
{ key: "language", label: "Browser language", example: "en-us" },
|
|
22
|
+
{ key: "utmSource", label: "UTM source", example: "newsletter" },
|
|
23
|
+
{ key: "utmMedium", label: "UTM medium", example: "email" },
|
|
24
|
+
{ key: "utmCampaign", label: "UTM campaign", example: "spring-launch" },
|
|
25
|
+
];
|
|
26
|
+
const TARGETING_OPERATORS = [
|
|
27
|
+
{ key: "equals", label: "is" },
|
|
28
|
+
{ key: "not_equals", label: "is not" },
|
|
29
|
+
{ key: "contains", label: "contains" },
|
|
30
|
+
{ key: "starts_with", label: "starts with" },
|
|
31
|
+
{ key: "ends_with", label: "ends with" },
|
|
32
|
+
{ key: "in", label: "is one of" },
|
|
33
|
+
{ key: "not_in", label: "is not one of" },
|
|
34
|
+
];
|
|
35
|
+
const LIST_OPERATORS = new Set(["in", "not_in"]);
|
|
36
|
+
|
|
37
|
+
function createEditorItemId(prefix) {
|
|
38
|
+
nextEditorItemId += 1;
|
|
39
|
+
return `${prefix}-${nextEditorItemId}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function emptyDefinition() {
|
|
43
|
+
return {
|
|
44
|
+
key: FLAG_KEY,
|
|
45
|
+
description: "Route visitors to the right destination using audience segments and rollouts.",
|
|
46
|
+
enabled: true,
|
|
47
|
+
defaultVariant: "control",
|
|
48
|
+
variants: [{ id: createEditorItemId("variant"), key: "control", url: "" }],
|
|
49
|
+
rules: [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeVariantUrl(variant) {
|
|
54
|
+
if (typeof variant?.value?.url === "string") return variant.value.url;
|
|
55
|
+
if (typeof variant?.url === "string") return variant.url;
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeSourceConditions(rule) {
|
|
60
|
+
if (Array.isArray(rule?.conditions)) return rule.conditions;
|
|
61
|
+
|
|
62
|
+
let legacyCountries = [];
|
|
63
|
+
if (Array.isArray(rule?.countries)) {
|
|
64
|
+
legacyCountries = rule.countries;
|
|
65
|
+
} else if (typeof rule?.country === "string") {
|
|
66
|
+
legacyCountries = [rule.country];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!legacyCountries.length) return [];
|
|
70
|
+
return [{ attribute: "country", operator: "in", value: legacyCountries }];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeEditorCondition(condition) {
|
|
74
|
+
const isKnownAttribute = FLAGSHIP_CONTEXT_FIELDS.some(
|
|
75
|
+
(field) => field.key === condition?.attribute
|
|
76
|
+
);
|
|
77
|
+
const isKnownOperator = TARGETING_OPERATORS.some(
|
|
78
|
+
(operator) => operator.key === condition?.operator
|
|
79
|
+
);
|
|
80
|
+
let value = "";
|
|
81
|
+
if (Array.isArray(condition?.value)) {
|
|
82
|
+
value = condition.value.join(", ");
|
|
83
|
+
} else if (typeof condition?.value === "string") {
|
|
84
|
+
value = condition.value;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id: createEditorItemId("condition"),
|
|
89
|
+
attribute: isKnownAttribute ? condition.attribute : "country",
|
|
90
|
+
operator: isKnownOperator ? condition.operator : "equals",
|
|
91
|
+
value,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeEditorRule(rule, index, variants) {
|
|
96
|
+
return {
|
|
97
|
+
id: createEditorItemId("rule"),
|
|
98
|
+
priority: Number.isInteger(rule?.priority) ? rule.priority : index + 1,
|
|
99
|
+
variant: typeof rule?.variant === "string" ? rule.variant : variants[0].key,
|
|
100
|
+
conditions: normalizeSourceConditions(rule).map(normalizeEditorCondition),
|
|
101
|
+
rolloutPercentage: Number.isFinite(rule?.rolloutPercentage) ? rule.rolloutPercentage : "",
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function normalizeDefaultVariant(flag, variants) {
|
|
106
|
+
if (typeof flag.defaultVariant === "string" && flag.defaultVariant) {
|
|
107
|
+
return flag.defaultVariant;
|
|
108
|
+
}
|
|
109
|
+
return variants[0].key;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeDefinition(flag) {
|
|
113
|
+
if (!flag) return emptyDefinition();
|
|
114
|
+
|
|
115
|
+
const variants = Array.isArray(flag.variants)
|
|
116
|
+
? flag.variants.map((variant) => ({
|
|
117
|
+
id: createEditorItemId("variant"),
|
|
118
|
+
key: typeof variant?.key === "string" ? variant.key : "",
|
|
119
|
+
url: normalizeVariantUrl(variant),
|
|
120
|
+
}))
|
|
121
|
+
: [];
|
|
122
|
+
const normalizedVariants = variants.length ? variants : emptyDefinition().variants;
|
|
123
|
+
const rules = Array.isArray(flag.rules)
|
|
124
|
+
? flag.rules
|
|
125
|
+
.map((rule, index) => normalizeEditorRule(rule, index, normalizedVariants))
|
|
126
|
+
.sort((left, right) => left.priority - right.priority)
|
|
127
|
+
.map((rule, index) => ({ ...rule, priority: index + 1 }))
|
|
128
|
+
: [];
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
key: typeof flag.key === "string" && flag.key ? flag.key : FLAG_KEY,
|
|
132
|
+
description: typeof flag.description === "string" ? flag.description : "",
|
|
133
|
+
enabled: flag.enabled === true,
|
|
134
|
+
defaultVariant: normalizeDefaultVariant(flag, normalizedVariants),
|
|
135
|
+
variants: normalizedVariants,
|
|
136
|
+
rules,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function conditionValueForPayload(condition) {
|
|
141
|
+
const value = condition.value.trim();
|
|
142
|
+
if (!LIST_OPERATORS.has(condition.operator)) {
|
|
143
|
+
return condition.attribute === "country" ? value.toUpperCase() : value;
|
|
144
|
+
}
|
|
145
|
+
return value
|
|
146
|
+
.split(",")
|
|
147
|
+
.map((item) => item.trim())
|
|
148
|
+
.filter(Boolean)
|
|
149
|
+
.map((item) => (condition.attribute === "country" ? item.toUpperCase() : item));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function toFlagPayload(definition) {
|
|
153
|
+
return {
|
|
154
|
+
key: FLAG_KEY,
|
|
155
|
+
description: definition.description.trim(),
|
|
156
|
+
enabled: definition.enabled,
|
|
157
|
+
defaultVariant: definition.defaultVariant,
|
|
158
|
+
variants: definition.variants.map((variant) => ({
|
|
159
|
+
key: variant.key.trim(),
|
|
160
|
+
value: { url: variant.url.trim() },
|
|
161
|
+
})),
|
|
162
|
+
rules: definition.rules.map((rule, index) => ({
|
|
163
|
+
priority: index + 1,
|
|
164
|
+
variant: rule.variant,
|
|
165
|
+
conditions: rule.conditions.map((condition) => ({
|
|
166
|
+
attribute: condition.attribute,
|
|
167
|
+
operator: condition.operator,
|
|
168
|
+
value: conditionValueForPayload(condition),
|
|
169
|
+
})),
|
|
170
|
+
...(rule.rolloutPercentage === "" ||
|
|
171
|
+
rule.rolloutPercentage === undefined ||
|
|
172
|
+
rule.rolloutPercentage === null
|
|
173
|
+
? {}
|
|
174
|
+
: { rolloutPercentage: Number(rule.rolloutPercentage) }),
|
|
175
|
+
})),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function draftVariantProblem(definition) {
|
|
180
|
+
if (!definition.variants.length) return "Add at least one destination variant.";
|
|
181
|
+
|
|
182
|
+
const keys = definition.variants.map((variant) => variant.key.trim()).filter(Boolean);
|
|
183
|
+
if (keys.length !== new Set(keys).size) return "Variant keys must be unique.";
|
|
184
|
+
if (keys.length !== definition.variants.length) return "Every variant needs a key.";
|
|
185
|
+
if (!keys.includes(definition.defaultVariant)) return "Choose an existing default variant.";
|
|
186
|
+
if (definition.variants.some((variant) => !variant.url.trim())) {
|
|
187
|
+
return "Every variant needs a destination URL.";
|
|
188
|
+
}
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function draftConditionProblem(condition) {
|
|
193
|
+
if (!condition.value.trim()) {
|
|
194
|
+
return "Complete each condition or remove it before saving.";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (condition.attribute === "country") {
|
|
198
|
+
const countryValues = LIST_OPERATORS.has(condition.operator)
|
|
199
|
+
? condition.value.split(",").map((value) => value.trim().toUpperCase())
|
|
200
|
+
: [condition.value.trim().toUpperCase()];
|
|
201
|
+
if (countryValues.some((value) => !/^[A-Z]{2}$/.test(value))) {
|
|
202
|
+
return "Country conditions need two-letter codes, such as US or CA.";
|
|
203
|
+
}
|
|
204
|
+
if (new Set(countryValues).size !== countryValues.length) {
|
|
205
|
+
return "Remove duplicate country codes from a condition.";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!LIST_OPERATORS.has(condition.operator)) return "";
|
|
210
|
+
const values = condition.value
|
|
211
|
+
.split(",")
|
|
212
|
+
.map((value) => value.trim())
|
|
213
|
+
.filter(Boolean);
|
|
214
|
+
if (!values.length) return "Enter at least one value for an ‘is one of’ condition.";
|
|
215
|
+
if (new Set(values).size !== values.length) {
|
|
216
|
+
return "Remove duplicate values from a condition.";
|
|
217
|
+
}
|
|
218
|
+
return "";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function draftRolloutProblem(rule) {
|
|
222
|
+
const hasRollout =
|
|
223
|
+
rule.rolloutPercentage !== "" &&
|
|
224
|
+
rule.rolloutPercentage !== undefined &&
|
|
225
|
+
rule.rolloutPercentage !== null;
|
|
226
|
+
if (!hasRollout) return "";
|
|
227
|
+
|
|
228
|
+
const percentage = Number(rule.rolloutPercentage);
|
|
229
|
+
if (
|
|
230
|
+
!Number.isFinite(percentage) ||
|
|
231
|
+
percentage < 0 ||
|
|
232
|
+
percentage > 100 ||
|
|
233
|
+
Math.abs(percentage * 100 - Math.round(percentage * 100)) > 1e-8
|
|
234
|
+
) {
|
|
235
|
+
return "Rollout percentages must be between 0 and 100, with at most two decimals.";
|
|
236
|
+
}
|
|
237
|
+
return "";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function draftRuleProblem(rule) {
|
|
241
|
+
for (const condition of rule.conditions) {
|
|
242
|
+
const problem = draftConditionProblem(condition);
|
|
243
|
+
if (problem) return problem;
|
|
244
|
+
}
|
|
245
|
+
return draftRolloutProblem(rule);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function draftProblem(definition) {
|
|
249
|
+
const variantProblem = draftVariantProblem(definition);
|
|
250
|
+
if (variantProblem) return variantProblem;
|
|
251
|
+
|
|
252
|
+
const variantKeys = new Set(definition.variants.map((variant) => variant.key.trim()));
|
|
253
|
+
if (definition.rules.some((rule) => !variantKeys.has(rule.variant))) {
|
|
254
|
+
return "Choose an existing variant for every segment.";
|
|
255
|
+
}
|
|
256
|
+
for (const rule of definition.rules) {
|
|
257
|
+
const problem = draftRuleProblem(rule);
|
|
258
|
+
if (problem) return problem;
|
|
259
|
+
}
|
|
260
|
+
return "";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function originHost(origin) {
|
|
264
|
+
try {
|
|
265
|
+
return new URL(origin).host;
|
|
266
|
+
} catch {
|
|
267
|
+
return origin;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function copyText(value) {
|
|
272
|
+
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
|
|
273
|
+
throw new Error("Copy is not available");
|
|
274
|
+
}
|
|
275
|
+
await navigator.clipboard.writeText(value);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function ShortenerPage({ config }) {
|
|
279
|
+
const [longUrl, setLongUrl] = useState("");
|
|
280
|
+
const [customSlug, setCustomSlug] = useState("");
|
|
281
|
+
const [shortUrl, setShortUrl] = useState("");
|
|
282
|
+
const [busy, setBusy] = useState(false);
|
|
283
|
+
const [copied, setCopied] = useState(false);
|
|
284
|
+
const [error, setError] = useState("");
|
|
285
|
+
|
|
286
|
+
async function handleSubmit(event) {
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
if (busy) return;
|
|
289
|
+
if (!longUrl.trim()) {
|
|
290
|
+
setError("Paste a destination URL first.");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
setBusy(true);
|
|
295
|
+
setCopied(false);
|
|
296
|
+
setError("");
|
|
297
|
+
setShortUrl("");
|
|
298
|
+
try {
|
|
299
|
+
const result = await shortenUrl({ url: longUrl, customSlug }, config);
|
|
300
|
+
setShortUrl(result);
|
|
301
|
+
} catch (requestError) {
|
|
302
|
+
setError(errorMessage(requestError.body, requestError.status, requestError.message));
|
|
303
|
+
} finally {
|
|
304
|
+
setBusy(false);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function handleCopy() {
|
|
309
|
+
try {
|
|
310
|
+
await copyText(shortUrl);
|
|
311
|
+
setCopied(true);
|
|
312
|
+
} catch (copyError) {
|
|
313
|
+
setError(copyError.message || "Could not copy the short URL.");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<div className="shorten-page">
|
|
319
|
+
<div className="page-heading">
|
|
320
|
+
<div>
|
|
321
|
+
<h1 id="shorten-page-title">Shorten URL</h1>
|
|
322
|
+
<p>Create a short link from a destination URL.</p>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<form className="shorten-card" onSubmit={handleSubmit}>
|
|
327
|
+
<div className="field-group">
|
|
328
|
+
<label htmlFor="long-url">Destination URL</label>
|
|
329
|
+
<input
|
|
330
|
+
id="long-url"
|
|
331
|
+
name="url"
|
|
332
|
+
type="url"
|
|
333
|
+
value={longUrl}
|
|
334
|
+
onChange={(event) => setLongUrl(event.target.value)}
|
|
335
|
+
placeholder="https://example.com/a/very/long/path"
|
|
336
|
+
autoComplete="url"
|
|
337
|
+
required
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
<div className="shorten-form__actions">
|
|
341
|
+
<div className="field-group">
|
|
342
|
+
<label htmlFor="custom-slug">
|
|
343
|
+
Custom slug <span className="label-note">optional</span>
|
|
344
|
+
</label>
|
|
345
|
+
<div className="slug-input">
|
|
346
|
+
<span className="slug-prefix">{originHost(config.workerOrigin)}/</span>
|
|
347
|
+
<input
|
|
348
|
+
id="custom-slug"
|
|
349
|
+
name="customSlug"
|
|
350
|
+
type="text"
|
|
351
|
+
value={customSlug}
|
|
352
|
+
onChange={(event) => setCustomSlug(event.target.value)}
|
|
353
|
+
placeholder="campaign-name"
|
|
354
|
+
maxLength={50}
|
|
355
|
+
autoComplete="off"
|
|
356
|
+
/>
|
|
357
|
+
</div>
|
|
358
|
+
<p className="field-help">Leave blank to generate a short key automatically.</p>
|
|
359
|
+
</div>
|
|
360
|
+
<button className="button button--primary button--wide" type="submit" disabled={busy}>
|
|
361
|
+
{busy ? "Creating link…" : "Shorten URL"}
|
|
362
|
+
</button>
|
|
363
|
+
</div>
|
|
364
|
+
{error && (
|
|
365
|
+
<p className="status status--error" role="alert">
|
|
366
|
+
{error}
|
|
367
|
+
</p>
|
|
368
|
+
)}
|
|
369
|
+
</form>
|
|
370
|
+
|
|
371
|
+
{shortUrl && (
|
|
372
|
+
<section className="result-card" aria-live="polite">
|
|
373
|
+
<div>
|
|
374
|
+
<p className="eyebrow">LINK READY</p>
|
|
375
|
+
<a className="result-url" href={shortUrl} target="_blank" rel="noreferrer">
|
|
376
|
+
{shortUrl}
|
|
377
|
+
</a>
|
|
378
|
+
</div>
|
|
379
|
+
<div className="result-actions">
|
|
380
|
+
<button className="button button--quiet" type="button" onClick={handleCopy}>
|
|
381
|
+
{copied ? "Copied" : "Copy link"}
|
|
382
|
+
</button>
|
|
383
|
+
<a className="button button--dark" href={shortUrl} target="_blank" rel="noreferrer">
|
|
384
|
+
Visit <span aria-hidden="true">↗</span>
|
|
385
|
+
</a>
|
|
386
|
+
</div>
|
|
387
|
+
</section>
|
|
388
|
+
)}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function DefinitionEditor({ definition, setDefinition, disabled }) {
|
|
394
|
+
function updateDefinition(update) {
|
|
395
|
+
setDefinition((current) => ({ ...current, ...update }));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function updateVariant(index, field, value) {
|
|
399
|
+
setDefinition((current) => ({
|
|
400
|
+
...current,
|
|
401
|
+
variants: current.variants.map((variant, variantIndex) =>
|
|
402
|
+
variantIndex === index ? { ...variant, [field]: value } : variant
|
|
403
|
+
),
|
|
404
|
+
}));
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function updateVariantKey(index, value) {
|
|
408
|
+
setDefinition((current) => {
|
|
409
|
+
const previousKey = current.variants[index]?.key;
|
|
410
|
+
return {
|
|
411
|
+
...current,
|
|
412
|
+
defaultVariant: current.defaultVariant === previousKey ? value : current.defaultVariant,
|
|
413
|
+
variants: current.variants.map((variant, variantIndex) =>
|
|
414
|
+
variantIndex === index ? { ...variant, key: value } : variant
|
|
415
|
+
),
|
|
416
|
+
rules: current.rules.map((rule) =>
|
|
417
|
+
rule.variant === previousKey ? { ...rule, variant: value } : rule
|
|
418
|
+
),
|
|
419
|
+
};
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function addVariant() {
|
|
424
|
+
setDefinition((current) => {
|
|
425
|
+
const taken = new Set(current.variants.map((variant) => variant.key));
|
|
426
|
+
let index = current.variants.length + 1;
|
|
427
|
+
let key = `variant-${index}`;
|
|
428
|
+
while (taken.has(key)) key = `variant-${++index}`;
|
|
429
|
+
return {
|
|
430
|
+
...current,
|
|
431
|
+
variants: [...current.variants, { id: createEditorItemId("variant"), key, url: "" }],
|
|
432
|
+
};
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function removeVariant(index) {
|
|
437
|
+
setDefinition((current) => {
|
|
438
|
+
if (current.variants.length <= 1) return current;
|
|
439
|
+
const removedKey = current.variants[index].key;
|
|
440
|
+
const variants = current.variants.filter((_, variantIndex) => variantIndex !== index);
|
|
441
|
+
return {
|
|
442
|
+
...current,
|
|
443
|
+
variants,
|
|
444
|
+
defaultVariant:
|
|
445
|
+
current.defaultVariant === removedKey ? variants[0].key : current.defaultVariant,
|
|
446
|
+
rules: current.rules.filter((rule) => rule.variant !== removedKey),
|
|
447
|
+
};
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function addRule() {
|
|
452
|
+
setDefinition((current) => ({
|
|
453
|
+
...current,
|
|
454
|
+
rules: [
|
|
455
|
+
...current.rules,
|
|
456
|
+
{
|
|
457
|
+
id: createEditorItemId("rule"),
|
|
458
|
+
priority: current.rules.length + 1,
|
|
459
|
+
variant: current.variants[0]?.key || "",
|
|
460
|
+
conditions: [
|
|
461
|
+
{
|
|
462
|
+
id: createEditorItemId("condition"),
|
|
463
|
+
attribute: "country",
|
|
464
|
+
operator: "in",
|
|
465
|
+
value: "US",
|
|
466
|
+
},
|
|
467
|
+
],
|
|
468
|
+
rolloutPercentage: "",
|
|
469
|
+
},
|
|
470
|
+
],
|
|
471
|
+
}));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function updateRule(index, field, value) {
|
|
475
|
+
setDefinition((current) => ({
|
|
476
|
+
...current,
|
|
477
|
+
rules: current.rules.map((rule, ruleIndex) =>
|
|
478
|
+
ruleIndex === index ? { ...rule, [field]: value } : rule
|
|
479
|
+
),
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function addCondition(ruleIndex) {
|
|
484
|
+
setDefinition((current) => ({
|
|
485
|
+
...current,
|
|
486
|
+
rules: current.rules.map((rule, index) =>
|
|
487
|
+
index === ruleIndex
|
|
488
|
+
? {
|
|
489
|
+
...rule,
|
|
490
|
+
conditions: [
|
|
491
|
+
...rule.conditions,
|
|
492
|
+
{
|
|
493
|
+
id: createEditorItemId("condition"),
|
|
494
|
+
attribute: "language",
|
|
495
|
+
operator: "equals",
|
|
496
|
+
value: "en-us",
|
|
497
|
+
},
|
|
498
|
+
],
|
|
499
|
+
}
|
|
500
|
+
: rule
|
|
501
|
+
),
|
|
502
|
+
}));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function updateCondition(ruleIndex, conditionIndex, field, value) {
|
|
506
|
+
setDefinition((current) => ({
|
|
507
|
+
...current,
|
|
508
|
+
rules: current.rules.map((rule, index) =>
|
|
509
|
+
index === ruleIndex
|
|
510
|
+
? {
|
|
511
|
+
...rule,
|
|
512
|
+
conditions: rule.conditions.map((condition, itemIndex) =>
|
|
513
|
+
itemIndex === conditionIndex ? { ...condition, [field]: value } : condition
|
|
514
|
+
),
|
|
515
|
+
}
|
|
516
|
+
: rule
|
|
517
|
+
),
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function removeCondition(ruleIndex, conditionIndex) {
|
|
522
|
+
setDefinition((current) => ({
|
|
523
|
+
...current,
|
|
524
|
+
rules: current.rules.map((rule, index) =>
|
|
525
|
+
index === ruleIndex
|
|
526
|
+
? {
|
|
527
|
+
...rule,
|
|
528
|
+
conditions: rule.conditions.filter((_, itemIndex) => itemIndex !== conditionIndex),
|
|
529
|
+
}
|
|
530
|
+
: rule
|
|
531
|
+
),
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function moveRule(ruleIndex, direction) {
|
|
536
|
+
setDefinition((current) => {
|
|
537
|
+
const nextIndex = ruleIndex + direction;
|
|
538
|
+
if (nextIndex < 0 || nextIndex >= current.rules.length) return current;
|
|
539
|
+
const rules = [...current.rules];
|
|
540
|
+
[rules[ruleIndex], rules[nextIndex]] = [rules[nextIndex], rules[ruleIndex]];
|
|
541
|
+
return { ...current, rules: rules.map((rule, index) => ({ ...rule, priority: index + 1 })) };
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return (
|
|
546
|
+
<fieldset className="flag-editor" disabled={disabled}>
|
|
547
|
+
<legend className="visually-hidden">Flag configuration</legend>
|
|
548
|
+
<nav className="flag-section-nav" aria-label="Flag configuration sections">
|
|
549
|
+
<a href="#flag-details">Details</a>
|
|
550
|
+
<a href="#flag-variants">
|
|
551
|
+
Variants <span>{definition.variants.length}</span>
|
|
552
|
+
</a>
|
|
553
|
+
<a href="#flag-rules">
|
|
554
|
+
Segments <span>{definition.rules.length}</span>
|
|
555
|
+
</a>
|
|
556
|
+
</nav>
|
|
557
|
+
|
|
558
|
+
<section id="flag-details" className="flag-section panel">
|
|
559
|
+
<div className="flag-section-heading">
|
|
560
|
+
<div>
|
|
561
|
+
<p className="eyebrow">01 / CONFIGURATION</p>
|
|
562
|
+
<h2>Flag details</h2>
|
|
563
|
+
</div>
|
|
564
|
+
<span className="flag-section-note">Shared settings for this flag</span>
|
|
565
|
+
</div>
|
|
566
|
+
<div className="flag-details-grid">
|
|
567
|
+
<div className="flag-key-field">
|
|
568
|
+
<span>Key</span>
|
|
569
|
+
<code>{FLAG_KEY}</code>
|
|
570
|
+
</div>
|
|
571
|
+
<div className="field-group flag-description-field">
|
|
572
|
+
<label htmlFor="flag-description">Description</label>
|
|
573
|
+
<textarea
|
|
574
|
+
id="flag-description"
|
|
575
|
+
value={definition.description}
|
|
576
|
+
onChange={(event) => updateDefinition({ description: event.target.value })}
|
|
577
|
+
rows={2}
|
|
578
|
+
maxLength={512}
|
|
579
|
+
/>
|
|
580
|
+
</div>
|
|
581
|
+
<div className="toggle-row">
|
|
582
|
+
<span>
|
|
583
|
+
<label htmlFor="flag-enabled">
|
|
584
|
+
<strong id="flag-enabled-label">Flag enabled</strong>
|
|
585
|
+
</label>
|
|
586
|
+
<small>When disabled, Flagship returns the default variant.</small>
|
|
587
|
+
</span>
|
|
588
|
+
<input
|
|
589
|
+
id="flag-enabled"
|
|
590
|
+
type="checkbox"
|
|
591
|
+
aria-labelledby="flag-enabled-label"
|
|
592
|
+
checked={definition.enabled}
|
|
593
|
+
onChange={(event) => updateDefinition({ enabled: event.target.checked })}
|
|
594
|
+
/>
|
|
595
|
+
<label className="toggle-control" htmlFor="flag-enabled">
|
|
596
|
+
<span className="visually-hidden">Flag enabled</span>
|
|
597
|
+
</label>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
</section>
|
|
601
|
+
|
|
602
|
+
<section id="flag-variants" className="flag-section panel">
|
|
603
|
+
<div className="flag-section-heading">
|
|
604
|
+
<div>
|
|
605
|
+
<p className="eyebrow">02 / VARIANTS</p>
|
|
606
|
+
<h2>Variants</h2>
|
|
607
|
+
<p className="helper-text">
|
|
608
|
+
Each variant is a destination URL. Choose the control or fallback with “Default”.
|
|
609
|
+
</p>
|
|
610
|
+
</div>
|
|
611
|
+
<div className="flag-section-tools">
|
|
612
|
+
<span className="count-chip">{definition.variants.length} total</span>
|
|
613
|
+
<button className="button button--small" type="button" onClick={addVariant}>
|
|
614
|
+
+ Add variant
|
|
615
|
+
</button>
|
|
616
|
+
</div>
|
|
617
|
+
</div>
|
|
618
|
+
<div className="variation-list">
|
|
619
|
+
{definition.variants.map((variant, index) => (
|
|
620
|
+
<div className="variation-row" key={variant.id}>
|
|
621
|
+
<div className="field-group variation-key-field">
|
|
622
|
+
<label htmlFor={`variant-key-${variant.id}`}>Key</label>
|
|
623
|
+
<input
|
|
624
|
+
id={`variant-key-${variant.id}`}
|
|
625
|
+
value={variant.key}
|
|
626
|
+
onChange={(event) => updateVariantKey(index, event.target.value)}
|
|
627
|
+
placeholder="control"
|
|
628
|
+
/>
|
|
629
|
+
</div>
|
|
630
|
+
<div className="field-group variation-url-field">
|
|
631
|
+
<label htmlFor={`variant-url-${variant.id}`}>Destination URL</label>
|
|
632
|
+
<input
|
|
633
|
+
id={`variant-url-${variant.id}`}
|
|
634
|
+
type="url"
|
|
635
|
+
value={variant.url}
|
|
636
|
+
onChange={(event) => updateVariant(index, "url", event.target.value)}
|
|
637
|
+
placeholder="https://example.com/control"
|
|
638
|
+
/>
|
|
639
|
+
</div>
|
|
640
|
+
<label className="default-variation" htmlFor={`variant-default-${variant.id}`}>
|
|
641
|
+
<input
|
|
642
|
+
id={`variant-default-${variant.id}`}
|
|
643
|
+
name="default-variation"
|
|
644
|
+
type="radio"
|
|
645
|
+
checked={definition.defaultVariant === variant.key}
|
|
646
|
+
onChange={() => updateDefinition({ defaultVariant: variant.key })}
|
|
647
|
+
/>
|
|
648
|
+
<span>Default</span>
|
|
649
|
+
</label>
|
|
650
|
+
<button
|
|
651
|
+
className="icon-button"
|
|
652
|
+
type="button"
|
|
653
|
+
onClick={() => removeVariant(index)}
|
|
654
|
+
disabled={definition.variants.length <= 1}
|
|
655
|
+
aria-label={`Remove ${variant.key || "variant"}`}
|
|
656
|
+
>
|
|
657
|
+
×
|
|
658
|
+
</button>
|
|
659
|
+
</div>
|
|
660
|
+
))}
|
|
661
|
+
</div>
|
|
662
|
+
</section>
|
|
663
|
+
|
|
664
|
+
<section id="flag-rules" className="flag-section panel">
|
|
665
|
+
<div className="flag-section-heading">
|
|
666
|
+
<div>
|
|
667
|
+
<p className="eyebrow">03 / AUDIENCE</p>
|
|
668
|
+
<h2>Segments</h2>
|
|
669
|
+
<p className="helper-text">
|
|
670
|
+
OpenFlagr calls these Segments: conditions are constraints, and rollout controls the
|
|
671
|
+
share sent to a variant. Segments run top to bottom; the first match wins.
|
|
672
|
+
</p>
|
|
673
|
+
</div>
|
|
674
|
+
<div className="flag-section-tools">
|
|
675
|
+
<span className="count-chip">{definition.rules.length} total</span>
|
|
676
|
+
<button className="button button--small" type="button" onClick={addRule}>
|
|
677
|
+
+ Add segment
|
|
678
|
+
</button>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
<div className="context-note">
|
|
682
|
+
<span aria-hidden="true">i</span>
|
|
683
|
+
<p>
|
|
684
|
+
Available context: country, region, continent, time zone, browser language, and UTM
|
|
685
|
+
source, medium, and campaign. If a request has no value for a condition, that segment
|
|
686
|
+
will not match.
|
|
687
|
+
</p>
|
|
688
|
+
</div>
|
|
689
|
+
{definition.rules.length === 0 ? (
|
|
690
|
+
<div className="segment-empty-state">
|
|
691
|
+
<span className="segment-empty-state__step">DEFAULT</span>
|
|
692
|
+
<div>
|
|
693
|
+
<strong>
|
|
694
|
+
Every visitor gets {definition.defaultVariant || "the default variant"}
|
|
695
|
+
</strong>
|
|
696
|
+
<p>Add a segment to target an audience or start a canary rollout.</p>
|
|
697
|
+
</div>
|
|
698
|
+
</div>
|
|
699
|
+
) : (
|
|
700
|
+
<div className="segment-list">
|
|
701
|
+
{definition.rules.map((rule, index) => (
|
|
702
|
+
<article className="segment-card" key={rule.id}>
|
|
703
|
+
<header className="segment-card__header">
|
|
704
|
+
<div className="segment-card__identity">
|
|
705
|
+
<span className="segment-priority">{index + 1}</span>
|
|
706
|
+
<div>
|
|
707
|
+
<h3>Segment {index + 1}</h3>
|
|
708
|
+
<p>
|
|
709
|
+
Priority {index + 1} · checked {index === 0 ? "first" : "in order"}
|
|
710
|
+
</p>
|
|
711
|
+
</div>
|
|
712
|
+
</div>
|
|
713
|
+
<div
|
|
714
|
+
className="segment-card__actions"
|
|
715
|
+
aria-label={`Segment ${index + 1} actions`}
|
|
716
|
+
>
|
|
717
|
+
<button
|
|
718
|
+
className="icon-button segment-order-button"
|
|
719
|
+
type="button"
|
|
720
|
+
onClick={() => moveRule(index, -1)}
|
|
721
|
+
disabled={index === 0}
|
|
722
|
+
aria-label={`Move segment ${index + 1} up`}
|
|
723
|
+
title="Move up"
|
|
724
|
+
>
|
|
725
|
+
↑
|
|
726
|
+
</button>
|
|
727
|
+
<button
|
|
728
|
+
className="icon-button segment-order-button"
|
|
729
|
+
type="button"
|
|
730
|
+
onClick={() => moveRule(index, 1)}
|
|
731
|
+
disabled={index === definition.rules.length - 1}
|
|
732
|
+
aria-label={`Move segment ${index + 1} down`}
|
|
733
|
+
title="Move down"
|
|
734
|
+
>
|
|
735
|
+
↓
|
|
736
|
+
</button>
|
|
737
|
+
<button
|
|
738
|
+
className="icon-button segment-remove-button"
|
|
739
|
+
type="button"
|
|
740
|
+
onClick={() =>
|
|
741
|
+
updateDefinition({
|
|
742
|
+
rules: definition.rules.filter((_, ruleIndex) => ruleIndex !== index),
|
|
743
|
+
})
|
|
744
|
+
}
|
|
745
|
+
aria-label={`Remove segment ${index + 1}`}
|
|
746
|
+
title="Remove segment"
|
|
747
|
+
>
|
|
748
|
+
×
|
|
749
|
+
</button>
|
|
750
|
+
</div>
|
|
751
|
+
</header>
|
|
752
|
+
|
|
753
|
+
<fieldset className="segment-stage segment-conditions">
|
|
754
|
+
<legend>
|
|
755
|
+
<span>IF</span> Conditions <small>all must match</small>
|
|
756
|
+
</legend>
|
|
757
|
+
{rule.conditions.length === 0 ? (
|
|
758
|
+
<p className="segment-all-visitors">
|
|
759
|
+
This segment matches everyone. Add a condition to narrow the audience.
|
|
760
|
+
</p>
|
|
761
|
+
) : (
|
|
762
|
+
<div className="condition-list">
|
|
763
|
+
{rule.conditions.map((condition, conditionIndex) => {
|
|
764
|
+
const contextField = FLAGSHIP_CONTEXT_FIELDS.find(
|
|
765
|
+
(field) => field.key === condition.attribute
|
|
766
|
+
);
|
|
767
|
+
const listCondition = LIST_OPERATORS.has(condition.operator);
|
|
768
|
+
const helpId = `condition-help-${rule.id}-${condition.id}`;
|
|
769
|
+
return (
|
|
770
|
+
<div className="condition-row" key={condition.id}>
|
|
771
|
+
<div className="field-group condition-attribute-field">
|
|
772
|
+
<label htmlFor={`condition-field-${condition.id}`}>Attribute</label>
|
|
773
|
+
<select
|
|
774
|
+
id={`condition-field-${condition.id}`}
|
|
775
|
+
value={condition.attribute}
|
|
776
|
+
onChange={(event) =>
|
|
777
|
+
updateCondition(
|
|
778
|
+
index,
|
|
779
|
+
conditionIndex,
|
|
780
|
+
"attribute",
|
|
781
|
+
event.target.value
|
|
782
|
+
)
|
|
783
|
+
}
|
|
784
|
+
>
|
|
785
|
+
{FLAGSHIP_CONTEXT_FIELDS.map((field) => (
|
|
786
|
+
<option key={field.key} value={field.key}>
|
|
787
|
+
{field.label}
|
|
788
|
+
</option>
|
|
789
|
+
))}
|
|
790
|
+
</select>
|
|
791
|
+
</div>
|
|
792
|
+
<div className="field-group condition-operator-field">
|
|
793
|
+
<label htmlFor={`condition-operator-${condition.id}`}>Operator</label>
|
|
794
|
+
<select
|
|
795
|
+
id={`condition-operator-${condition.id}`}
|
|
796
|
+
value={condition.operator}
|
|
797
|
+
onChange={(event) =>
|
|
798
|
+
updateCondition(
|
|
799
|
+
index,
|
|
800
|
+
conditionIndex,
|
|
801
|
+
"operator",
|
|
802
|
+
event.target.value
|
|
803
|
+
)
|
|
804
|
+
}
|
|
805
|
+
>
|
|
806
|
+
{TARGETING_OPERATORS.map((operator) => (
|
|
807
|
+
<option key={operator.key} value={operator.key}>
|
|
808
|
+
{operator.label}
|
|
809
|
+
</option>
|
|
810
|
+
))}
|
|
811
|
+
</select>
|
|
812
|
+
</div>
|
|
813
|
+
<div className="field-group condition-value-field">
|
|
814
|
+
<label htmlFor={`condition-value-${condition.id}`}>Value</label>
|
|
815
|
+
<input
|
|
816
|
+
id={`condition-value-${condition.id}`}
|
|
817
|
+
value={condition.value}
|
|
818
|
+
onChange={(event) =>
|
|
819
|
+
updateCondition(
|
|
820
|
+
index,
|
|
821
|
+
conditionIndex,
|
|
822
|
+
"value",
|
|
823
|
+
event.target.value
|
|
824
|
+
)
|
|
825
|
+
}
|
|
826
|
+
placeholder={
|
|
827
|
+
listCondition ? "US, CA" : contextField?.example || "Value"
|
|
828
|
+
}
|
|
829
|
+
aria-describedby={helpId}
|
|
830
|
+
maxLength={256}
|
|
831
|
+
/>
|
|
832
|
+
<small id={helpId}>
|
|
833
|
+
{listCondition
|
|
834
|
+
? "Separate values with commas."
|
|
835
|
+
: `Example: ${contextField?.example || "value"}.`}
|
|
836
|
+
</small>
|
|
837
|
+
</div>
|
|
838
|
+
<button
|
|
839
|
+
className="icon-button condition-remove-button"
|
|
840
|
+
type="button"
|
|
841
|
+
onClick={() => removeCondition(index, conditionIndex)}
|
|
842
|
+
aria-label={`Remove condition ${conditionIndex + 1} from segment ${index + 1}`}
|
|
843
|
+
title="Remove condition"
|
|
844
|
+
>
|
|
845
|
+
×
|
|
846
|
+
</button>
|
|
847
|
+
</div>
|
|
848
|
+
);
|
|
849
|
+
})}
|
|
850
|
+
</div>
|
|
851
|
+
)}
|
|
852
|
+
<button
|
|
853
|
+
className="button button--quiet button--small condition-add-button"
|
|
854
|
+
type="button"
|
|
855
|
+
onClick={() => addCondition(index)}
|
|
856
|
+
>
|
|
857
|
+
+ Add condition
|
|
858
|
+
</button>
|
|
859
|
+
</fieldset>
|
|
860
|
+
|
|
861
|
+
<div className="segment-outcome-grid">
|
|
862
|
+
<fieldset className="segment-stage segment-rollout">
|
|
863
|
+
<legend>Rollout</legend>
|
|
864
|
+
<label className="rollout-toggle" htmlFor={`rollout-enabled-${rule.id}`}>
|
|
865
|
+
<input
|
|
866
|
+
id={`rollout-enabled-${rule.id}`}
|
|
867
|
+
type="checkbox"
|
|
868
|
+
checked={rule.rolloutPercentage !== ""}
|
|
869
|
+
onChange={(event) =>
|
|
870
|
+
updateRule(index, "rolloutPercentage", event.target.checked ? "10" : "")
|
|
871
|
+
}
|
|
872
|
+
/>
|
|
873
|
+
<span>Use gradual rollout</span>
|
|
874
|
+
</label>
|
|
875
|
+
{rule.rolloutPercentage !== "" ? (
|
|
876
|
+
<div className="rollout-controls">
|
|
877
|
+
<label className="visually-hidden" htmlFor={`rollout-slider-${rule.id}`}>
|
|
878
|
+
Rollout percentage slider
|
|
879
|
+
</label>
|
|
880
|
+
<input
|
|
881
|
+
className="rollout-slider"
|
|
882
|
+
id={`rollout-slider-${rule.id}`}
|
|
883
|
+
type="range"
|
|
884
|
+
min="0"
|
|
885
|
+
max="100"
|
|
886
|
+
step="0.01"
|
|
887
|
+
value={rule.rolloutPercentage}
|
|
888
|
+
onChange={(event) =>
|
|
889
|
+
updateRule(index, "rolloutPercentage", event.target.value)
|
|
890
|
+
}
|
|
891
|
+
aria-valuetext={`${rule.rolloutPercentage}% of matching visitors`}
|
|
892
|
+
/>
|
|
893
|
+
<div className="field-group rollout-number-field">
|
|
894
|
+
<label htmlFor={`rollout-value-${rule.id}`}>
|
|
895
|
+
Percent of matching visitors
|
|
896
|
+
</label>
|
|
897
|
+
<div className="rollout-number-input">
|
|
898
|
+
<input
|
|
899
|
+
id={`rollout-value-${rule.id}`}
|
|
900
|
+
type="number"
|
|
901
|
+
inputMode="decimal"
|
|
902
|
+
min="0"
|
|
903
|
+
max="100"
|
|
904
|
+
step="0.01"
|
|
905
|
+
value={rule.rolloutPercentage}
|
|
906
|
+
onChange={(event) =>
|
|
907
|
+
updateRule(index, "rolloutPercentage", event.target.value)
|
|
908
|
+
}
|
|
909
|
+
/>
|
|
910
|
+
<span aria-hidden="true">%</span>
|
|
911
|
+
</div>
|
|
912
|
+
</div>
|
|
913
|
+
<p className="rollout-help">
|
|
914
|
+
The same anonymous browser stays in the same cohort. Visitors outside this
|
|
915
|
+
rollout continue to the next segment or default.
|
|
916
|
+
</p>
|
|
917
|
+
</div>
|
|
918
|
+
) : (
|
|
919
|
+
<p className="rollout-help">
|
|
920
|
+
Off means every visitor who matches these conditions gets the selected
|
|
921
|
+
variant.
|
|
922
|
+
</p>
|
|
923
|
+
)}
|
|
924
|
+
</fieldset>
|
|
925
|
+
|
|
926
|
+
<div className="segment-stage segment-serve">
|
|
927
|
+
<label htmlFor={`rule-variant-${rule.id}`}>THEN · Serve variant</label>
|
|
928
|
+
<select
|
|
929
|
+
id={`rule-variant-${rule.id}`}
|
|
930
|
+
value={rule.variant}
|
|
931
|
+
onChange={(event) => updateRule(index, "variant", event.target.value)}
|
|
932
|
+
>
|
|
933
|
+
{definition.variants.map((variant) => (
|
|
934
|
+
<option key={`${rule.id}-${variant.id}`} value={variant.key}>
|
|
935
|
+
{variant.key || "Unnamed"}
|
|
936
|
+
{definition.defaultVariant === variant.key ? " · default" : ""}
|
|
937
|
+
</option>
|
|
938
|
+
))}
|
|
939
|
+
</select>
|
|
940
|
+
</div>
|
|
941
|
+
</div>
|
|
942
|
+
</article>
|
|
943
|
+
))}
|
|
944
|
+
</div>
|
|
945
|
+
)}
|
|
946
|
+
<div className="rule-fallback">
|
|
947
|
+
<span className="rule-fallback__marker" aria-hidden="true">
|
|
948
|
+
↳
|
|
949
|
+
</span>
|
|
950
|
+
<div>
|
|
951
|
+
<span>DEFAULT VARIANT</span>
|
|
952
|
+
<strong>{definition.defaultVariant || "Choose a variant"}</strong>
|
|
953
|
+
</div>
|
|
954
|
+
<p>Used when no segment matches, and when the flag is disabled.</p>
|
|
955
|
+
</div>
|
|
956
|
+
</section>
|
|
957
|
+
</fieldset>
|
|
958
|
+
);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function SettingsPage({ config }) {
|
|
962
|
+
const [definition, setDefinition] = useState(emptyDefinition);
|
|
963
|
+
const [csrfToken, setCsrfToken] = useState("");
|
|
964
|
+
const [version, setVersion] = useState("");
|
|
965
|
+
const [hasFlag, setHasFlag] = useState(false);
|
|
966
|
+
const [phase, setPhase] = useState("loading");
|
|
967
|
+
const [busy, setBusy] = useState("");
|
|
968
|
+
const [status, setStatus] = useState({ tone: "neutral", message: "Loading settings…" });
|
|
969
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
970
|
+
|
|
971
|
+
useEffect(() => {
|
|
972
|
+
let cancelled = false;
|
|
973
|
+
setPhase("loading");
|
|
974
|
+
setStatus({ tone: "neutral", message: "Loading settings…" });
|
|
975
|
+
if (config.isLocalDevelopment) {
|
|
976
|
+
try {
|
|
977
|
+
const flag = loadLocalFlagMock();
|
|
978
|
+
setCsrfToken("");
|
|
979
|
+
setHasFlag(Boolean(flag));
|
|
980
|
+
setVersion(flag?.updatedAt || "");
|
|
981
|
+
setDefinition(normalizeDefinition(flag));
|
|
982
|
+
setPhase("ready");
|
|
983
|
+
setStatus({
|
|
984
|
+
tone: "neutral",
|
|
985
|
+
message: "Local mock mode. Changes stay in this browser and never reach Flagship.",
|
|
986
|
+
});
|
|
987
|
+
} catch (error) {
|
|
988
|
+
setPhase("error");
|
|
989
|
+
setStatus({ tone: "error", message: error.message || "Could not load the local mock." });
|
|
990
|
+
}
|
|
991
|
+
return undefined;
|
|
992
|
+
}
|
|
993
|
+
loadSettings(config)
|
|
994
|
+
.then(({ csrfToken: nextToken, flag }) => {
|
|
995
|
+
if (cancelled) return;
|
|
996
|
+
setCsrfToken(nextToken);
|
|
997
|
+
setHasFlag(Boolean(flag));
|
|
998
|
+
setVersion(flag?.updatedAt || "");
|
|
999
|
+
setDefinition(normalizeDefinition(flag));
|
|
1000
|
+
setPhase("ready");
|
|
1001
|
+
setStatus({
|
|
1002
|
+
tone: "success",
|
|
1003
|
+
message: flag
|
|
1004
|
+
? "Live definition loaded."
|
|
1005
|
+
: "No live flag yet. Save a definition to create it.",
|
|
1006
|
+
});
|
|
1007
|
+
})
|
|
1008
|
+
.catch((error) => {
|
|
1009
|
+
if (cancelled) return;
|
|
1010
|
+
setPhase("error");
|
|
1011
|
+
setStatus({
|
|
1012
|
+
tone: "error",
|
|
1013
|
+
message: errorMessage(
|
|
1014
|
+
error.body,
|
|
1015
|
+
error.status,
|
|
1016
|
+
error.message || "Could not load settings."
|
|
1017
|
+
),
|
|
1018
|
+
});
|
|
1019
|
+
});
|
|
1020
|
+
return () => {
|
|
1021
|
+
cancelled = true;
|
|
1022
|
+
};
|
|
1023
|
+
}, [config, reloadKey]);
|
|
1024
|
+
|
|
1025
|
+
async function handleSave() {
|
|
1026
|
+
if (busy || phase !== "ready") return;
|
|
1027
|
+
const problem = draftProblem(definition);
|
|
1028
|
+
if (problem) {
|
|
1029
|
+
setStatus({ tone: "error", message: problem });
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
setBusy("save");
|
|
1034
|
+
setStatus({ tone: "neutral", message: "Saving the draft…" });
|
|
1035
|
+
try {
|
|
1036
|
+
const payload = toFlagPayload(definition);
|
|
1037
|
+
let result;
|
|
1038
|
+
if (config.isLocalDevelopment) {
|
|
1039
|
+
result = saveLocalFlagMock(payload);
|
|
1040
|
+
} else if (hasFlag) {
|
|
1041
|
+
result = await updateShorteningFlag(payload, version, config, csrfToken);
|
|
1042
|
+
} else {
|
|
1043
|
+
result = await createShorteningFlag(payload, config, csrfToken);
|
|
1044
|
+
}
|
|
1045
|
+
if (!result?.flag) throw new Error("The Worker returned no saved flag");
|
|
1046
|
+
setDefinition(normalizeDefinition(result.flag));
|
|
1047
|
+
setVersion(result.flag.updatedAt || version);
|
|
1048
|
+
setHasFlag(true);
|
|
1049
|
+
setStatus({
|
|
1050
|
+
tone: "success",
|
|
1051
|
+
message: config.isLocalDevelopment
|
|
1052
|
+
? "Local mock saved in this browser. No real Flagship data changed."
|
|
1053
|
+
: "Draft saved. Publish when the destinations are ready.",
|
|
1054
|
+
});
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
setStatus({
|
|
1057
|
+
tone: "error",
|
|
1058
|
+
message: errorMessage(
|
|
1059
|
+
error.body,
|
|
1060
|
+
error.status,
|
|
1061
|
+
error.message || "Could not save the draft."
|
|
1062
|
+
),
|
|
1063
|
+
});
|
|
1064
|
+
} finally {
|
|
1065
|
+
setBusy("");
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
async function handlePublish() {
|
|
1070
|
+
if (busy || phase !== "ready" || !hasFlag || !version) return;
|
|
1071
|
+
setBusy("publish");
|
|
1072
|
+
setStatus({ tone: "neutral", message: "Publishing the current draft…" });
|
|
1073
|
+
try {
|
|
1074
|
+
const result = config.isLocalDevelopment
|
|
1075
|
+
? publishLocalFlagMock(version)
|
|
1076
|
+
: await publishShorteningFlag(version, config, csrfToken);
|
|
1077
|
+
if (!result?.flag) throw new Error("The Worker returned no published flag");
|
|
1078
|
+
setDefinition(normalizeDefinition(result.flag));
|
|
1079
|
+
setVersion(result.flag.updatedAt || version);
|
|
1080
|
+
setStatus({
|
|
1081
|
+
tone: "success",
|
|
1082
|
+
message: config.isLocalDevelopment
|
|
1083
|
+
? "Local mock published in this browser. Real links are unchanged."
|
|
1084
|
+
: "Published. New links can now use the updated routing rules.",
|
|
1085
|
+
});
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
setStatus({
|
|
1088
|
+
tone: "error",
|
|
1089
|
+
message: errorMessage(
|
|
1090
|
+
error.body,
|
|
1091
|
+
error.status,
|
|
1092
|
+
error.message || "Could not publish the draft."
|
|
1093
|
+
),
|
|
1094
|
+
});
|
|
1095
|
+
} finally {
|
|
1096
|
+
setBusy("");
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const editorDisabled = phase !== "ready" || Boolean(busy);
|
|
1101
|
+
let settingsState = config.isLocalDevelopment ? "LOCAL MOCK" : "OFFLINE";
|
|
1102
|
+
if (!config.isLocalDevelopment && phase === "loading") settingsState = "CONNECTING";
|
|
1103
|
+
if (!config.isLocalDevelopment && phase === "ready") settingsState = "CONNECTED";
|
|
1104
|
+
let saveLabel = "Create flag";
|
|
1105
|
+
if (hasFlag) saveLabel = "Save draft";
|
|
1106
|
+
if (busy === "save") saveLabel = "Saving…";
|
|
1107
|
+
|
|
1108
|
+
return (
|
|
1109
|
+
<div className="flag-manager">
|
|
1110
|
+
<div className="flag-breadcrumb" aria-label="Breadcrumb">
|
|
1111
|
+
<a href="#/">Shorten URL</a>
|
|
1112
|
+
<span aria-hidden="true">/</span>
|
|
1113
|
+
<span aria-current="page">Flagship</span>
|
|
1114
|
+
<span aria-hidden="true">/</span>
|
|
1115
|
+
<code>{FLAG_KEY}</code>
|
|
1116
|
+
</div>
|
|
1117
|
+
|
|
1118
|
+
<header className="flag-manager-header">
|
|
1119
|
+
<div className="flag-manager-title">
|
|
1120
|
+
<p className="eyebrow">FEATURE FLAG</p>
|
|
1121
|
+
<h1 id="flag-page-title">{FLAG_KEY}</h1>
|
|
1122
|
+
<p>Route visitors with audience segments, destination variants, and gradual rollouts.</p>
|
|
1123
|
+
</div>
|
|
1124
|
+
<div className="flag-state-list" aria-label="Flag state">
|
|
1125
|
+
<span className={`state-badge state-badge--${phase}`}>{settingsState}</span>
|
|
1126
|
+
<span
|
|
1127
|
+
className={`flag-state-chip ${definition.enabled ? "flag-state-chip--enabled" : "flag-state-chip--disabled"}`}
|
|
1128
|
+
>
|
|
1129
|
+
{definition.enabled ? "ENABLED" : "DISABLED"}
|
|
1130
|
+
</span>
|
|
1131
|
+
<span
|
|
1132
|
+
className={`flag-state-chip ${hasFlag ? "flag-state-chip--saved" : "flag-state-chip--new"}`}
|
|
1133
|
+
>
|
|
1134
|
+
{hasFlag ? "DRAFT SAVED" : "NEW FLAG"}
|
|
1135
|
+
</span>
|
|
1136
|
+
</div>
|
|
1137
|
+
</header>
|
|
1138
|
+
|
|
1139
|
+
<dl className="flag-summary-strip" aria-label="Flag summary">
|
|
1140
|
+
<div>
|
|
1141
|
+
<dt>FLAG KEY</dt>
|
|
1142
|
+
<dd>
|
|
1143
|
+
<code>{FLAG_KEY}</code>
|
|
1144
|
+
</dd>
|
|
1145
|
+
</div>
|
|
1146
|
+
<div>
|
|
1147
|
+
<dt>WORKER ORIGIN</dt>
|
|
1148
|
+
<dd title={config.workerOrigin}>{originHost(config.workerOrigin)}</dd>
|
|
1149
|
+
</div>
|
|
1150
|
+
<div>
|
|
1151
|
+
<dt>VARIANTS</dt>
|
|
1152
|
+
<dd>{definition.variants.length}</dd>
|
|
1153
|
+
</div>
|
|
1154
|
+
<div>
|
|
1155
|
+
<dt>SEGMENTS</dt>
|
|
1156
|
+
<dd>{definition.rules.length}</dd>
|
|
1157
|
+
</div>
|
|
1158
|
+
</dl>
|
|
1159
|
+
|
|
1160
|
+
<DefinitionEditor
|
|
1161
|
+
definition={definition}
|
|
1162
|
+
setDefinition={setDefinition}
|
|
1163
|
+
disabled={editorDisabled}
|
|
1164
|
+
/>
|
|
1165
|
+
|
|
1166
|
+
<div className="flag-action-bar">
|
|
1167
|
+
<p
|
|
1168
|
+
className={`status status--${status.tone}`}
|
|
1169
|
+
role={status.tone === "error" ? "alert" : undefined}
|
|
1170
|
+
aria-live="polite"
|
|
1171
|
+
>
|
|
1172
|
+
{status.message}
|
|
1173
|
+
</p>
|
|
1174
|
+
<div className="flag-action-buttons">
|
|
1175
|
+
<button
|
|
1176
|
+
className="button button--quiet"
|
|
1177
|
+
type="button"
|
|
1178
|
+
onClick={() => setReloadKey((key) => key + 1)}
|
|
1179
|
+
disabled={Boolean(busy)}
|
|
1180
|
+
>
|
|
1181
|
+
Reload
|
|
1182
|
+
</button>
|
|
1183
|
+
<button
|
|
1184
|
+
className="button button--dark"
|
|
1185
|
+
type="button"
|
|
1186
|
+
onClick={handleSave}
|
|
1187
|
+
disabled={editorDisabled}
|
|
1188
|
+
>
|
|
1189
|
+
{saveLabel}
|
|
1190
|
+
</button>
|
|
1191
|
+
<button
|
|
1192
|
+
className="button button--primary"
|
|
1193
|
+
type="button"
|
|
1194
|
+
onClick={handlePublish}
|
|
1195
|
+
disabled={editorDisabled || !hasFlag || !version}
|
|
1196
|
+
>
|
|
1197
|
+
{busy === "publish" ? "Publishing…" : "Publish"}
|
|
1198
|
+
</button>
|
|
1199
|
+
</div>
|
|
1200
|
+
</div>
|
|
1201
|
+
</div>
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
export default function App() {
|
|
1206
|
+
const config = useMemo(() => createFrontendConfig(), []);
|
|
1207
|
+
const currentPage = () =>
|
|
1208
|
+
typeof window !== "undefined" && window.location.hash === "#/flagship" ? "settings" : "shorten";
|
|
1209
|
+
const [activePage, setActivePage] = useState(currentPage);
|
|
1210
|
+
const [settingsVisited, setSettingsVisited] = useState(() => currentPage() === "settings");
|
|
1211
|
+
|
|
1212
|
+
useEffect(() => {
|
|
1213
|
+
function handleLocationChange() {
|
|
1214
|
+
const page = currentPage();
|
|
1215
|
+
setActivePage(page);
|
|
1216
|
+
if (page === "settings") setSettingsVisited(true);
|
|
1217
|
+
window.scrollTo(0, 0);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
window.addEventListener("hashchange", handleLocationChange);
|
|
1221
|
+
return () => window.removeEventListener("hashchange", handleLocationChange);
|
|
1222
|
+
}, []);
|
|
1223
|
+
|
|
1224
|
+
return (
|
|
1225
|
+
<div className="app-shell">
|
|
1226
|
+
<main className={`main-content${activePage === "settings" ? " main-content--settings" : ""}`}>
|
|
1227
|
+
<section
|
|
1228
|
+
id="shorten-page"
|
|
1229
|
+
aria-labelledby="shorten-page-title"
|
|
1230
|
+
hidden={activePage !== "shorten"}
|
|
1231
|
+
>
|
|
1232
|
+
<ShortenerPage config={config} />
|
|
1233
|
+
</section>
|
|
1234
|
+
<section
|
|
1235
|
+
id="flagship-page"
|
|
1236
|
+
aria-labelledby="flag-page-title"
|
|
1237
|
+
hidden={activePage !== "settings"}
|
|
1238
|
+
>
|
|
1239
|
+
{settingsVisited ? <SettingsPage config={config} /> : null}
|
|
1240
|
+
</section>
|
|
1241
|
+
<footer className="app-footer">
|
|
1242
|
+
<nav aria-label="Project links">
|
|
1243
|
+
<a href="https://github.com/dytsou/shorten-url" target="_blank" rel="noreferrer">
|
|
1244
|
+
GitHub
|
|
1245
|
+
</a>
|
|
1246
|
+
<a href="/about">Docs</a>
|
|
1247
|
+
<a href="/api/">API</a>
|
|
1248
|
+
</nav>
|
|
1249
|
+
<p>
|
|
1250
|
+
© 2026 dytsou. Licensed under{" "}
|
|
1251
|
+
<a
|
|
1252
|
+
href="https://github.com/dytsou/shorten-url/blob/main/LICENSE"
|
|
1253
|
+
target="_blank"
|
|
1254
|
+
rel="noreferrer"
|
|
1255
|
+
>
|
|
1256
|
+
MIT License
|
|
1257
|
+
</a>
|
|
1258
|
+
{"."}
|
|
1259
|
+
</p>
|
|
1260
|
+
</footer>
|
|
1261
|
+
</main>
|
|
1262
|
+
</div>
|
|
1263
|
+
);
|
|
1264
|
+
}
|