@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.
@@ -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
+ }