• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / 3f1a4922-6438-402b-8a31-b68b7edbe989

pending completion
3f1a4922-6438-402b-8a31-b68b7edbe989

push

circleci

GitHub Actions — l10n sync
Merge in latest l10n strings

1561 of 2384 branches covered (65.48%)

Branch coverage included in aggregate %.

5107 of 6949 relevant lines covered (73.49%)

19.12 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

89.22
/frontend/src/components/landing/PlanMatrix.tsx
1
import { useTab, useTabList, useTabPanel } from "react-aria";
2✔
2
import { Key, ReactNode, useRef } from "react";
2✔
3
import Link from "next/link";
2✔
4
import { event as gaEvent } from "react-ga";
2✔
5
import {
6
  Item,
7
  TabListProps,
8
  TabListState,
9
  useTabListState,
10
} from "react-stately";
2✔
11
import styles from "./PlanMatrix.module.scss";
2✔
12
import {
13
  getBundlePrice,
14
  getBundleSubscribeLink,
15
  getPeriodicalPremiumPrice,
16
  getPeriodicalPremiumSubscribeLink,
17
  getPhonesPrice,
18
  getPhoneSubscribeLink,
19
  isBundleAvailableInCountry,
20
  isPeriodicalPremiumAvailableInCountry,
21
  isPhonesAvailableInCountry,
22
} from "../../functions/getPlan";
2✔
23
import { RuntimeData } from "../../hooks/api/runtimeData";
24
import { CheckIcon, MozillaVpnWordmark } from "../Icons";
2✔
25
import { getRuntimeConfig } from "../../config";
2✔
26
import { useGaViewPing } from "../../hooks/gaViewPing";
2✔
27
import { Plan, trackPlanPurchaseStart } from "../../functions/trackPurchase";
2✔
28
import { setCookie } from "../../functions/cookies";
2✔
29
import { useL10n } from "../../hooks/l10n";
2✔
30
import { Localized } from "../Localized";
2✔
31
import { LinkButton } from "../Button";
2✔
32
import { VisuallyHidden } from "../VisuallyHidden";
2✔
33

34
type FeatureList = {
35
  "email-masks": number;
36
  "browser-extension": boolean;
37
  "email-tracker-removal": boolean;
38
  "promo-email-blocking": boolean;
39
  "email-subdomain": boolean;
40
  "email-reply": boolean;
41
  "phone-mask": boolean;
42
  vpn: boolean;
43
};
44

45
const freeFeatures: FeatureList = {
2✔
46
  "email-masks": 5,
47
  "browser-extension": true,
48
  "email-tracker-removal": true,
49
  "promo-email-blocking": false,
50
  "email-subdomain": false,
51
  "email-reply": false,
52
  "phone-mask": false,
53
  vpn: false,
54
};
55
const premiumFeatures: FeatureList = {
2✔
56
  ...freeFeatures,
57
  "email-masks": Number.POSITIVE_INFINITY,
58
  "promo-email-blocking": true,
59
  "email-subdomain": true,
60
  "email-reply": true,
61
};
62
const phoneFeatures: FeatureList = {
2✔
63
  ...premiumFeatures,
64
  "phone-mask": true,
65
};
66
const bundleFeatures: FeatureList = {
2✔
67
  ...phoneFeatures,
68
  vpn: true,
69
};
70

71
export type Props = {
72
  runtimeData?: RuntimeData;
73
};
74

75
/**
76
 * Matrix to compare and choose between the different plans available to the user.
77
 */
78
export const PlanMatrix = (props: Props) => {
12✔
79
  const l10n = useL10n();
12✔
80
  const freeButtonDesktopRef = useGaViewPing({
12✔
81
    category: "Sign In",
82
    label: "plan-matrix-free-cta-desktop",
83
  });
84
  const bundleButtonDesktopRef = useGaViewPing({
12✔
85
    category: "Purchase Bundle button",
86
    label: "plan-matrix-bundle-cta-desktop",
87
  });
88
  const freeButtonMobileRef = useGaViewPing({
12✔
89
    category: "Sign In",
90
    label: "plan-matrix-free-cta-mobile",
91
  });
92
  const bundleButtonMobileRef = useGaViewPing({
12✔
93
    category: "Purchase Bundle button",
94
    label: "plan-matrix-bundle-cta-mobile",
95
  });
96

97
  const countSignIn = (label: string) => {
12✔
98
    gaEvent({
×
99
      category: "Sign In",
100
      action: "Engage",
101
      label: label,
102
    });
103
    setCookie("user-sign-in", "true", { maxAgeInSeconds: 60 * 60 });
×
104
  };
105

106
  const desktopView = (
107
    <table className={styles.desktop}>
108
      <thead>
109
        <tr>
110
          <th scope="col">{l10n.getString("plan-matrix-heading-features")}</th>
111
          <th scope="col">{l10n.getString("plan-matrix-heading-plan-free")}</th>
112
          <th scope="col">
113
            {l10n.getString("plan-matrix-heading-plan-premium")}
114
          </th>
115
          <th scope="col">
116
            {l10n.getString("plan-matrix-heading-plan-phones")}
117
          </th>
118
          {isBundleAvailableInCountry(props.runtimeData) ? (
12✔
119
            <th scope="col" className={styles.recommended}>
120
              <b>{l10n.getString("plan-matrix-heading-plan-bundle")}</b>
121
            </th>
122
          ) : (
123
            <th scope="col">
124
              {l10n.getString("plan-matrix-heading-plan-bundle")}
125
            </th>
126
          )}
127
        </tr>
128
      </thead>
129
      <tbody>
130
        <DesktopFeature runtimeData={props.runtimeData} feature="email-masks" />
131
        <DesktopFeature
132
          runtimeData={props.runtimeData}
133
          feature="browser-extension"
134
        />
135
        <DesktopFeature
136
          runtimeData={props.runtimeData}
137
          feature="email-tracker-removal"
138
        />
139
        <DesktopFeature
140
          runtimeData={props.runtimeData}
141
          feature="promo-email-blocking"
142
        />
143
        <DesktopFeature
144
          runtimeData={props.runtimeData}
145
          feature="email-subdomain"
146
        />
147
        <DesktopFeature runtimeData={props.runtimeData} feature="email-reply" />
148
        <DesktopFeature runtimeData={props.runtimeData} feature="phone-mask" />
149
        <DesktopFeature runtimeData={props.runtimeData} feature="vpn" />
150
      </tbody>
151
      <tfoot>
152
        <tr>
153
          <th scope="row">
154
            <VisuallyHidden>
155
              {l10n.getString("plan-matrix-heading-price")}
156
            </VisuallyHidden>
157
          </th>
158
          <td>
159
            <div className={`${styles.pricing} ${styles["single-price"]}`}>
160
              <div className={styles["pricing-overview"]}>
161
                <span className={styles.price}>
162
                  {l10n.getString("plan-matrix-price-free")}
163
                </span>
164
                <LinkButton
165
                  ref={freeButtonDesktopRef}
166
                  href={getRuntimeConfig().fxaLoginUrl}
167
                  onClick={() => countSignIn("plan-matrix-free-cta-desktop")}
×
168
                  className={styles["primary-pick-button"]}
169
                >
170
                  {l10n.getString("plan-matrix-get-relay-cta")}
171
                </LinkButton>
172
                {/*
173
                The <small> has space for price-related notices (e.g. "* billed
174
                annually"). When there is no such notice, we still want to leave
175
                space for it to prevent the page from jumping around; hence the
176
                empty <small>.
177
                */}
178
                <small>&nbsp;</small>
179
              </div>
180
            </div>
181
          </td>
182
          <td>
183
            {isPeriodicalPremiumAvailableInCountry(props.runtimeData) ? (
12✔
184
              <PricingToggle
185
                monthlyBilled={{
186
                  monthly_price: getPeriodicalPremiumPrice(
187
                    props.runtimeData,
188
                    "monthly",
189
                    l10n
190
                  ),
191
                  subscribeLink: getPeriodicalPremiumSubscribeLink(
192
                    props.runtimeData,
193
                    "monthly"
194
                  ),
195
                  gaViewPing: {
196
                    category: "Purchase monthly Premium button",
197
                    label: "plan-matrix-premium-monthly-cta-desktop",
198
                  },
199
                  plan: {
200
                    plan: "premium",
201
                    billing_period: "monthly",
202
                  },
203
                }}
204
                yearlyBilled={{
205
                  monthly_price: getPeriodicalPremiumPrice(
206
                    props.runtimeData,
207
                    "yearly",
208
                    l10n
209
                  ),
210
                  subscribeLink: getPeriodicalPremiumSubscribeLink(
211
                    props.runtimeData,
212
                    "yearly"
213
                  ),
214
                  gaViewPing: {
215
                    category: "Purchase yearly Premium button",
216
                    label: "plan-matrix-premium-yearly-cta-desktop",
217
                  },
218
                  plan: {
219
                    plan: "premium",
220
                    billing_period: "yearly",
221
                  },
222
                }}
223
              />
224
            ) : (
225
              <div className={`${styles.pricing} ${styles["single-price"]}`}>
226
                <div className={styles["pricing-overview"]}>
227
                  <span className={styles.price}>
228
                    {/* Clunky method to make sure the .pick-button is aligned
229
                        with the buttons for plans that do display a price */}
230
                    &nbsp;
231
                  </span>
232
                  <Link
233
                    href="/premium/waitlist"
234
                    className={styles["pick-button"]}
235
                  >
236
                    {l10n.getString("plan-matrix-join-waitlist")}
237
                  </Link>
238
                  {/*
239
                  The <small> has space for price-related notices (e.g. "* billed
240
                  annually"). When there is no such notice, we still want to leave
241
                  space for it to prevent the page from jumping around; hence the
242
                  empty <small>.
243
                  */}
244
                  <small>&nbsp;</small>
245
                </div>
246
              </div>
247
            )}
248
          </td>
249
          <td>
250
            {isPhonesAvailableInCountry(props.runtimeData) ? (
12✔
251
              <PricingToggle
252
                monthlyBilled={{
253
                  monthly_price: getPhonesPrice(
254
                    props.runtimeData,
255
                    "monthly",
256
                    l10n
257
                  ),
258
                  subscribeLink: getPhoneSubscribeLink(
259
                    props.runtimeData,
260
                    "monthly"
261
                  ),
262
                  gaViewPing: {
263
                    category: "Purchase monthly Premium+phones button",
264
                    label: "plan-matrix-phone-monthly-cta-desktop",
265
                  },
266
                  plan: {
267
                    plan: "phones",
268
                    billing_period: "monthly",
269
                  },
270
                }}
271
                yearlyBilled={{
272
                  monthly_price: getPhonesPrice(
273
                    props.runtimeData,
274
                    "yearly",
275
                    l10n
276
                  ),
277
                  subscribeLink: getPhoneSubscribeLink(
278
                    props.runtimeData,
279
                    "yearly"
280
                  ),
281
                  gaViewPing: {
282
                    category: "Purchase yearly Premium+phones button",
283
                    label: "plan-matrix-phone-yearly-cta-desktop",
284
                  },
285
                  plan: {
286
                    plan: "phones",
287
                    billing_period: "yearly",
288
                  },
289
                }}
290
              />
291
            ) : (
292
              <div className={`${styles.pricing} ${styles["single-price"]}`}>
293
                <div className={styles["pricing-overview"]}>
294
                  <span className={styles.price}>
295
                    {/* Clunky method to make sure the .pick-button is aligned
296
                        with the buttons for plans that do display a price */}
297
                    &nbsp;
298
                  </span>
299
                  <Link
300
                    href="/phone/waitlist"
301
                    className={styles["pick-button"]}
302
                  >
303
                    {l10n.getString("plan-matrix-join-waitlist")}
304
                  </Link>
305
                  <small>
306
                    {l10n.getString(
307
                      "plan-matrix-price-period-monthly-footnote-1"
308
                    )}
309
                  </small>
310
                </div>
311
              </div>
312
            )}
313
          </td>
314
          <td>
315
            {isBundleAvailableInCountry(props.runtimeData) ? (
12✔
316
              <div className={`${styles.pricing}`}>
317
                <div className={styles["pricing-toggle-wrapper"]}>
318
                  <p className={styles["discount-notice-wrapper"]}>
319
                    <Localized
320
                      id="plan-matrix-price-vpn-discount-promo"
321
                      vars={{
322
                        savings: "40%",
323
                      }}
324
                      elems={{
325
                        span: (
326
                          <span className={styles["discount-notice-bolded"]} />
327
                        ),
328
                      }}
329
                    >
330
                      <span className={styles["discount-notice-container"]} />
331
                    </Localized>
332
                  </p>
333
                </div>
334
                <div className={styles["pricing-overview"]}>
335
                  <span className={styles.price}>
336
                    {l10n.getString("plan-matrix-price-monthly-calculated", {
337
                      monthly_price: getBundlePrice(props.runtimeData, l10n),
338
                    })}
339
                  </span>
340
                  <a
341
                    ref={bundleButtonDesktopRef}
342
                    href={getBundleSubscribeLink(props.runtimeData)}
343
                    onClick={() =>
344
                      trackPlanPurchaseStart(
×
345
                        { plan: "bundle" },
346
                        { label: "plan-matrix-bundle-cta-desktop" }
347
                      )
348
                    }
349
                    className={styles["pick-button"]}
350
                  >
351
                    {l10n.getString("plan-matrix-sign-up")}
352
                  </a>
353
                  <small>
354
                    {l10n.getString(
355
                      "plan-matrix-price-period-yearly-footnote-1"
356
                    )}
357
                  </small>
358
                </div>
359
              </div>
360
            ) : (
361
              <div className={`${styles.pricing} ${styles["single-price"]}`}>
362
                <div className={styles["pricing-overview"]}>
363
                  <span className={styles.price}>
364
                    {/* Clunky method to make sure the .pick-button is aligned
365
                        with the buttons for plans that do display a price */}
366
                    &nbsp;
367
                  </span>
368
                  <Link
369
                    href="/vpn-relay/waitlist"
370
                    className={styles["pick-button"]}
371
                  >
372
                    {l10n.getString("plan-matrix-join-waitlist")}
373
                  </Link>
374
                  <small>
375
                    {l10n.getString(
376
                      "plan-matrix-price-period-monthly-footnote-1"
377
                    )}
378
                  </small>
379
                </div>
380
              </div>
381
            )}
382
          </td>
383
        </tr>
384
      </tfoot>
385
    </table>
386
  );
387

388
  const mobileView = (
389
    <div className={styles.mobile}>
390
      <ul className={styles.plans}>
391
        <li className={styles.plan}>
392
          <h3>{l10n.getString("plan-matrix-heading-plan-free")}</h3>
393
          <MobileFeatureList list={freeFeatures} />
394
          <div className={styles.pricing}>
395
            <div className={styles["pricing-overview"]}>
396
              <span className={styles.price}>
397
                {l10n.getString("plan-matrix-price-free")}
398
              </span>
399
              <LinkButton
400
                ref={freeButtonMobileRef}
401
                href={getRuntimeConfig().fxaLoginUrl}
402
                onClick={() => countSignIn("plan-matrix-free-cta-mobile")}
×
403
                className={styles["primary-pick-button"]}
404
              >
405
                {l10n.getString("plan-matrix-get-relay-cta")}
406
              </LinkButton>
407
            </div>
408
          </div>
409
        </li>
410
        <li className={styles.plan}>
411
          <h3>{l10n.getString("plan-matrix-heading-plan-premium")}</h3>
412
          <MobileFeatureList list={premiumFeatures} />
413
          {isPeriodicalPremiumAvailableInCountry(props.runtimeData) ? (
12✔
414
            <PricingToggle
415
              monthlyBilled={{
416
                monthly_price: getPeriodicalPremiumPrice(
417
                  props.runtimeData,
418
                  "monthly",
419
                  l10n
420
                ),
421
                subscribeLink: getPeriodicalPremiumSubscribeLink(
422
                  props.runtimeData,
423
                  "monthly"
424
                ),
425
                gaViewPing: {
426
                  category: "Purchase monthly Premium button",
427
                  label: "plan-matrix-premium-monthly-cta-mobile",
428
                },
429
                plan: {
430
                  plan: "premium",
431
                  billing_period: "monthly",
432
                },
433
              }}
434
              yearlyBilled={{
435
                monthly_price: getPeriodicalPremiumPrice(
436
                  props.runtimeData,
437
                  "yearly",
438
                  l10n
439
                ),
440
                subscribeLink: getPeriodicalPremiumSubscribeLink(
441
                  props.runtimeData,
442
                  "yearly"
443
                ),
444
                gaViewPing: {
445
                  category: "Purchase yearly Premium button",
446
                  label: "plan-matrix-premium-yearly-cta-mobile",
447
                },
448
                plan: {
449
                  plan: "premium",
450
                  billing_period: "yearly",
451
                },
452
              }}
453
            />
454
          ) : (
455
            <div className={styles.pricing}>
456
              <div className={styles["pricing-overview"]}>
457
                <span className={styles.price}>
458
                  {/* Clunky method to make sure that there's whitespace
459
                      where the prices are for other plans on the same row. */}
460
                  &nbsp;
461
                </span>
462
                <Link
463
                  href="/premium/waitlist"
464
                  className={styles["pick-button"]}
465
                >
466
                  {l10n.getString("plan-matrix-join-waitlist")}
467
                </Link>
468
              </div>
469
            </div>
470
          )}
471
        </li>
472
        <li className={styles.plan}>
473
          <h3>{l10n.getString("plan-matrix-heading-plan-phones")}</h3>
474
          <MobileFeatureList list={phoneFeatures} />
475
          {isPhonesAvailableInCountry(props.runtimeData) ? (
12✔
476
            <PricingToggle
477
              monthlyBilled={{
478
                monthly_price: getPhonesPrice(
479
                  props.runtimeData,
480
                  "monthly",
481
                  l10n
482
                ),
483
                subscribeLink: getPhoneSubscribeLink(
484
                  props.runtimeData,
485
                  "monthly"
486
                ),
487
                gaViewPing: {
488
                  category: "Purchase monthly Premium+phones button",
489
                  label: "plan-matrix-phone-monthly-cta-mobile",
490
                },
491
                plan: {
492
                  plan: "phones",
493
                  billing_period: "monthly",
494
                },
495
              }}
496
              yearlyBilled={{
497
                monthly_price: getPhonesPrice(
498
                  props.runtimeData,
499
                  "yearly",
500
                  l10n
501
                ),
502
                subscribeLink: getPhoneSubscribeLink(
503
                  props.runtimeData,
504
                  "yearly"
505
                ),
506
                gaViewPing: {
507
                  category: "Purchase yearly Premium+phones button",
508
                  label: "plan-matrix-phone-yearly-cta-mobile",
509
                },
510
                plan: {
511
                  plan: "phones",
512
                  billing_period: "yearly",
513
                },
514
              }}
515
            />
516
          ) : (
517
            <div className={styles.pricing}>
518
              <div className={styles["pricing-overview"]}>
519
                <span className={styles.price}>
520
                  {/* Clunky method to make sure that there's whitespace
521
                        where the prices are for other plans on the same row. */}
522
                  &nbsp;
523
                </span>
524
                <Link href="/phone/waitlist" className={styles["pick-button"]}>
525
                  {l10n.getString("plan-matrix-join-waitlist")}
526
                </Link>
527
              </div>
528
            </div>
529
          )}
530
        </li>
531
        <li
532
          className={`${styles.plan} ${
533
            isBundleAvailableInCountry(props.runtimeData)
12✔
534
              ? styles.recommended
535
              : ""
536
          }`}
537
        >
538
          <h3>{l10n.getString("plan-matrix-heading-plan-bundle")}</h3>
539
          <MobileFeatureList list={bundleFeatures} />
540
          {isBundleAvailableInCountry(props.runtimeData) ? (
12✔
541
            <div className={styles.pricing}>
542
              <div className={styles["pricing-toggle-wrapper"]}>
543
                <p className={styles["discount-notice-wrapper"]}>
544
                  <Localized
545
                    id="plan-matrix-price-vpn-discount-promo"
546
                    vars={{
547
                      savings: "40%",
548
                    }}
549
                    elems={{
550
                      span: (
551
                        <span className={styles["discount-notice-bolded"]} />
552
                      ),
553
                    }}
554
                  >
555
                    <span />
556
                  </Localized>
557
                </p>
558
              </div>
559
              <div className={styles["pricing-overview"]}>
560
                <span className={styles.price}>
561
                  {l10n.getString("plan-matrix-price-monthly-calculated", {
562
                    monthly_price: getBundlePrice(props.runtimeData, l10n),
563
                  })}
564
                </span>
565
                <a
566
                  ref={bundleButtonMobileRef}
567
                  href={getBundleSubscribeLink(props.runtimeData)}
568
                  onClick={() =>
569
                    trackPlanPurchaseStart(
×
570
                      { plan: "bundle" },
571
                      { label: "plan-matrix-bundle-cta-mobile" }
572
                    )
573
                  }
574
                  className={styles["pick-button"]}
575
                >
576
                  {l10n.getString("plan-matrix-sign-up")}
577
                </a>
578
                <small>
579
                  {l10n.getString("plan-matrix-price-period-yearly-footnote-1")}
580
                </small>
581
              </div>
582
            </div>
583
          ) : (
584
            <div className={styles.pricing}>
585
              <div className={styles["pricing-overview"]}>
586
                <span className={styles.price}>
587
                  {/* Clunky method to make sure that there's whitespace
588
                        where the prices are for other plans on the same row. */}
589
                  &nbsp;
590
                </span>
591
                <Link
592
                  href="/vpn-relay/waitlist"
593
                  className={styles["pick-button"]}
594
                >
595
                  {l10n.getString("plan-matrix-join-waitlist")}
596
                </Link>
597
              </div>
598
            </div>
599
          )}
600
        </li>
601
      </ul>
602
    </div>
603
  );
604

605
  return (
606
    <div className={styles.wrapper}>
607
      {isBundleAvailableInCountry(props.runtimeData) && (
12✔
608
        <h2 className={styles["bundle-offer-heading"]}>
609
          {l10n.getString("plan-matrix-offer-title", {
610
            monthly_price: getBundlePrice(props.runtimeData, l10n),
611
          })}
612
        </h2>
613
      )}
614
      {isPeriodicalPremiumAvailableInCountry(props.runtimeData) && (
12✔
615
        <p className={styles["bundle-offer-content"]}>
616
          {l10n.getString("plan-matrix-offer-body", { savings: "40%" })}
617
        </p>
618
      )}
619
      <section id="pricing" className={styles["table-wrapper"]}>
620
        {desktopView}
621
        {mobileView}
622
      </section>
623
    </div>
624
  );
625
};
626

627
type DesktopFeatureProps = {
628
  feature: keyof FeatureList;
629
  runtimeData?: RuntimeData;
630
};
631
const DesktopFeature = (props: DesktopFeatureProps) => {
2✔
632
  return (
633
    <tr>
634
      <Localized
635
        id={`plan-matrix-feature-${props.feature}`}
636
        elems={{
637
          "vpn-logo": <VpnWordmark />,
638
        }}
639
      >
640
        <th scope="row" />
641
      </Localized>
642
      <td>
643
        <AvailabilityListing availability={freeFeatures[props.feature]} />
644
      </td>
645
      <td>
646
        <AvailabilityListing availability={premiumFeatures[props.feature]} />
647
      </td>
648
      <td>
649
        <AvailabilityListing availability={phoneFeatures[props.feature]} />
650
      </td>
651
      <td>
652
        <AvailabilityListing availability={bundleFeatures[props.feature]} />
653
      </td>
654
    </tr>
655
  );
656
};
657

658
type MobileFeatureListProps = {
659
  list: FeatureList;
660
};
661
const MobileFeatureList = (props: MobileFeatureListProps) => {
2✔
662
  const l10n = useL10n();
48✔
663

664
  const lis = Object.entries(props.list)
48✔
665
    .filter(
666
      ([_feature, availability]) =>
667
        typeof availability !== "boolean" || availability
384✔
668
    )
669
    .map(([feature, availability]) => {
670
      const variables =
671
        typeof availability === "number"
288✔
672
          ? { mask_limit: availability }
673
          : undefined;
674
      const featureDescription =
675
        feature === "email-masks" && availability === Number.POSITIVE_INFINITY
288✔
676
          ? l10n.getString("plan-matrix-feature-list-email-masks-unlimited")
677
          : l10n.getString(`plan-matrix-feature-mobile-${feature}`, variables);
678

679
      return (
288✔
680
        <li key={feature}>
681
          <Localized
682
            id={`plan-matrix-feature-mobile-${feature}`}
683
            elems={{
684
              "vpn-logo": <VpnWordmark />,
685
            }}
686
          >
687
            <span
688
              className={styles.description}
689
              // The aria label makes sure that listings like "Email masks"
690
              // with a number in span.availability get read by screen readers
691
              // as "5 email masks" rather than "Email masks 5".
692
              // However, the VPN feature has an image in there, marked up as
693
              // <vpn-logo> in the Fluent localisation file, so we don't want
694
              // that read out loud. And since the VPN feature doesn't contain
695
              // a number, we can skip overriding its aria-label.
696
              aria-label={feature !== "vpn" ? featureDescription : undefined}
288✔
697
            />
698
          </Localized>
699
          <span aria-hidden={true} className={styles.availability}>
700
            <AvailabilityListing availability={availability} />
701
          </span>
702
        </li>
703
      );
704
    });
705

706
  return <ul className={styles["feature-list"]}>{lis}</ul>;
707
};
708

709
type AvailabilityListingProps = {
710
  availability: FeatureList[keyof FeatureList];
711
};
712
const AvailabilityListing = (props: AvailabilityListingProps) => {
2✔
713
  const l10n = useL10n();
672✔
714

715
  if (typeof props.availability === "number") {
672✔
716
    if (props.availability === Number.POSITIVE_INFINITY) {
96✔
717
      return <>{l10n.getString("plan-matrix-feature-count-unlimited")}</>;
718
    }
719
    return <>{props.availability}</>;
720
  }
721

722
  if (typeof props.availability === "boolean") {
576✔
723
    return props.availability ? (
576✔
724
      <CheckIcon alt={l10n.getString("plan-matrix-feature-included")} />
725
    ) : (
726
      <VisuallyHidden>
727
        {l10n.getString("plan-matrix-feature-not-included")}
728
      </VisuallyHidden>
729
    );
730
  }
731

732
  return null as never;
×
733
};
734

735
type PricingToggleProps = {
736
  yearlyBilled: {
737
    monthly_price: string;
738
    subscribeLink: string;
739
    gaViewPing: Parameters<typeof useGaViewPing>[0];
740
    plan: Plan;
741
  };
742
  monthlyBilled: {
743
    monthly_price: string;
744
    subscribeLink: string;
745
    gaViewPing: Parameters<typeof useGaViewPing>[0];
746
    plan: Plan;
747
  };
748
};
749
const PricingToggle = (props: PricingToggleProps) => {
2✔
750
  const l10n = useL10n();
22✔
751
  const yearlyButtonRef = useGaViewPing(props.yearlyBilled.gaViewPing);
22✔
752
  const monthlyButtonRef = useGaViewPing(props.monthlyBilled.gaViewPing);
22✔
753

754
  return (
755
    <PricingTabs defaultSelectedKey="yearly">
756
      <Item
757
        key="yearly"
758
        title={l10n.getString("plan-matrix-price-period-yearly")}
759
      >
760
        <span className={styles.price}>
761
          {l10n.getString("plan-matrix-price-monthly-calculated", {
762
            monthly_price: props.yearlyBilled.monthly_price,
763
          })}
764
        </span>
765
        <a
766
          ref={yearlyButtonRef}
767
          href={props.yearlyBilled.subscribeLink}
768
          onClick={() =>
769
            trackPlanPurchaseStart(props.yearlyBilled.plan, {
×
770
              label: props.yearlyBilled.gaViewPing?.label,
771
            })
772
          }
773
          // tabIndex tells react-aria that this element is focusable
774
          tabIndex={0}
775
          className={styles["pick-button"]}
776
        >
777
          {l10n.getString("plan-matrix-sign-up")}
778
        </a>
779
        <small>
780
          {l10n.getString("plan-matrix-price-period-yearly-footnote-1")}
781
        </small>
782
      </Item>
783
      <Item
784
        key="monthly"
785
        title={l10n.getString("plan-matrix-price-period-monthly")}
786
      >
787
        <span className={styles.price}>
788
          {l10n.getString("plan-matrix-price-monthly-calculated", {
789
            monthly_price: props.monthlyBilled.monthly_price,
790
          })}
791
        </span>
792
        <a
793
          ref={monthlyButtonRef}
794
          href={props.monthlyBilled.subscribeLink}
795
          onClick={() =>
796
            trackPlanPurchaseStart(props.monthlyBilled.plan, {
×
797
              label: props.monthlyBilled.gaViewPing?.label,
798
            })
799
          }
800
          // tabIndex tells react-aria that this element is focusable
801
          tabIndex={0}
802
          className={styles["pick-button"]}
803
        >
804
          {l10n.getString("plan-matrix-sign-up")}
805
        </a>
806
        <small>
807
          {l10n.getString("plan-matrix-price-period-monthly-footnote-1")}
808
        </small>
809
      </Item>
810
    </PricingTabs>
811
  );
812
};
813

814
const PricingTabs = (props: TabListProps<object>) => {
2✔
815
  const tabListState = useTabListState(props);
66✔
816
  const tabListRef = useRef(null);
66✔
817
  const { tabListProps } = useTabList(props, tabListState, tabListRef);
66✔
818
  const tabPanelRef = useRef(null);
66✔
819
  const { tabPanelProps } = useTabPanel({}, tabListState, tabPanelRef);
66✔
820

821
  return (
822
    <div className={styles.pricing}>
823
      <div className={styles["pricing-toggle-wrapper"]}>
824
        <div
825
          {...tabListProps}
826
          ref={tabListRef}
827
          className={styles["pricing-toggle"]}
828
        >
829
          {Array.from(tabListState.collection).map((item) => (
830
            <PricingTab key={item.key} item={item} state={tabListState} />
132✔
831
          ))}
832
        </div>
833
      </div>
834
      <div
835
        {...tabPanelProps}
836
        ref={tabPanelRef}
837
        className={styles["pricing-overview"]}
838
      >
839
        {tabListState.selectedItem?.props.children}
840
      </div>
841
    </div>
842
  );
843
};
844

845
const PricingTab = (props: {
2✔
846
  state: TabListState<object>;
847
  item: { key: Key; rendered: ReactNode };
848
}) => {
849
  const tabRef = useRef(null);
88✔
850
  const { tabProps } = useTab({ key: props.item.key }, props.state, tabRef);
88✔
851
  return (
852
    <div
853
      {...tabProps}
854
      ref={tabRef}
855
      className={
856
        props.state.selectedKey === props.item.key ? styles["is-selected"] : ""
88✔
857
      }
858
    >
859
      {props.item.rendered}
860
    </div>
861
  );
862
};
863

864
const VpnWordmark = (props: { children?: string }) => {
2✔
865
  return (
866
    <>
867
      &nbsp;
868
      <MozillaVpnWordmark alt={props.children ?? "Mozilla VPN"} />
×
869
    </>
870
  );
871
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc