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

mozilla / fx-private-relay / c62e0eb2-eb5c-40bd-97c7-0b45c334f9bf

26 Mar 2025 09:19PM CUT coverage: 85.194%. Remained the same
c62e0eb2-eb5c-40bd-97c7-0b45c334f9bf

Pull #5470

circleci

vpremamozilla
more lint fixes
Pull Request #5470: MPP-3976 'Get Relay' Button Loop

2459 of 3595 branches covered (68.4%)

Branch coverage included in aggregate %.

6 of 6 new or added lines in 2 files covered. (100.0%)

6 existing lines in 1 file now uncovered.

17220 of 19504 relevant lines covered (88.29%)

9.83 hits per line

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

86.41
/frontend/src/components/landing/PlanMatrix.tsx
1
import { useTab, useTabList, useTabPanel } from "react-aria";
2✔
2
import { ReactNode, useRef } from "react";
2✔
3
import Link from "next/link";
2✔
4
import {
5
  Item,
6
  TabListProps,
7
  TabListState,
8
  useTabListState,
9
} from "react-stately";
2✔
10
import styles from "./PlanMatrix.module.scss";
2✔
11
import {
12
  getBundlePrice,
13
  getBundleSubscribeLink,
14
  getPeriodicalPremiumPrice,
15
  getPeriodicalPremiumSubscribeLink,
16
  getPhonesPrice,
17
  getPhoneSubscribeLink,
18
  isBundleAvailableInCountry,
19
  isPeriodicalPremiumAvailableInCountry,
20
  isPhonesAvailableInCountry,
21
} from "../../functions/getPlan";
2✔
22
import { RuntimeData } from "../../hooks/api/runtimeData";
23
import { CheckIcon, MozillaVpnWordmark } from "../Icons";
2✔
24
import { getRuntimeConfig } from "../../config";
2✔
25
import { useGaEvent } from "../../hooks/gaEvent";
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
import { useUsers } from "../../hooks/api/user";
2✔
34

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

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

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

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

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

108
  const userData = useUsers();
12✔
109

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

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

616
  return (
617
    <div className={styles.wrapper}>
618
      {isBundleAvailableInCountry(props.runtimeData) && (
12✔
619
        <h2 className={styles["bundle-offer-heading"]}>
620
          {l10n.getString("plan-matrix-offer-title", {
621
            monthly_price: getBundlePrice(props.runtimeData, l10n),
622
          })}
623
        </h2>
624
      )}
625
      {isPeriodicalPremiumAvailableInCountry(props.runtimeData) && (
12✔
626
        <p className={styles["bundle-offer-content"]}>
627
          {l10n.getString("plan-matrix-offer-body", { savings: "40%" })}
628
        </p>
629
      )}
630
      <section id="pricing" className={styles["table-wrapper"]}>
631
        {desktopView}
632
        {mobileView}
633
      </section>
634
    </div>
635
  );
636
};
637

638
type DesktopFeatureProps = {
639
  feature: keyof FeatureList;
640
  runtimeData?: RuntimeData;
641
};
642
const DesktopFeature = (props: DesktopFeatureProps) => {
2✔
643
  return (
644
    <tr>
645
      <Localized
646
        id={`plan-matrix-feature-${props.feature}`}
647
        elems={{
648
          "vpn-logo": <VpnWordmark />,
649
        }}
650
      >
651
        <th scope="row" />
652
      </Localized>
653
      <td>
654
        <AvailabilityListing availability={freeFeatures[props.feature]} />
655
      </td>
656
      <td>
657
        <AvailabilityListing availability={premiumFeatures[props.feature]} />
658
      </td>
659
      <td>
660
        <AvailabilityListing availability={phoneFeatures[props.feature]} />
661
      </td>
662
      <td>
663
        <AvailabilityListing availability={bundleFeatures[props.feature]} />
664
      </td>
665
    </tr>
666
  );
667
};
668

669
type MobileFeatureListProps = {
670
  list: FeatureList;
671
};
672
const MobileFeatureList = (props: MobileFeatureListProps) => {
2✔
673
  const l10n = useL10n();
48✔
674

675
  const lis = Object.entries(props.list)
48✔
676
    .filter(
677
      ([_feature, availability]) =>
678
        typeof availability !== "boolean" || availability,
384✔
679
    )
680
    .map(([feature, availability]) => {
681
      const variables =
682
        typeof availability === "number"
288✔
683
          ? { mask_limit: availability }
684
          : undefined;
685
      const featureDescription =
686
        feature === "email-masks" && availability === Number.POSITIVE_INFINITY
288✔
687
          ? l10n.getString("plan-matrix-feature-list-email-masks-unlimited")
688
          : l10n.getString(`plan-matrix-feature-mobile-${feature}`, variables);
689

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

717
  return <ul className={styles["feature-list"]}>{lis}</ul>;
718
};
719

720
type AvailabilityListingProps = {
721
  availability: FeatureList[keyof FeatureList];
722
};
723
const AvailabilityListing = (props: AvailabilityListingProps) => {
2✔
724
  const l10n = useL10n();
672✔
725

726
  if (typeof props.availability === "number") {
672✔
727
    if (props.availability === Number.POSITIVE_INFINITY) {
96✔
728
      return <>{l10n.getString("plan-matrix-feature-count-unlimited")}</>;
729
    }
730
    return <>{props.availability}</>;
731
  }
732

733
  if (typeof props.availability === "boolean") {
576✔
734
    return props.availability ? (
735
      <CheckIcon alt={l10n.getString("plan-matrix-feature-included")} />
480✔
736
    ) : (
737
      <VisuallyHidden>
738
        {l10n.getString("plan-matrix-feature-not-included")}
739
      </VisuallyHidden>
740
    );
741
  }
742

UNCOV
743
  return null as never;
×
744
};
745

746
type PricingToggleProps = {
747
  yearlyBilled: {
748
    monthly_price: string;
749
    subscribeLink: string;
750
    gaViewPing: Parameters<typeof useGaViewPing>[0];
751
    plan: Plan;
752
  };
753
  monthlyBilled: {
754
    monthly_price: string;
755
    subscribeLink: string;
756
    gaViewPing: Parameters<typeof useGaViewPing>[0];
757
    plan: Plan;
758
  };
759
};
760
const PricingToggle = (props: PricingToggleProps) => {
2✔
761
  const l10n = useL10n();
22✔
762
  const gaEvent = useGaEvent();
22✔
763
  const yearlyButtonRef = useGaViewPing(props.yearlyBilled.gaViewPing);
22✔
764
  const monthlyButtonRef = useGaViewPing(props.monthlyBilled.gaViewPing);
22✔
765

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

826
const PricingTabs = (props: TabListProps<object>) => {
2✔
827
  const tabListState = useTabListState(props);
66✔
828
  const tabListRef = useRef(null);
66✔
829
  const { tabListProps } = useTabList(props, tabListState, tabListRef);
66✔
830
  const tabPanelRef = useRef(null);
66✔
831
  const { tabPanelProps } = useTabPanel({}, tabListState, tabPanelRef);
66✔
832

833
  return (
834
    <div className={styles.pricing}>
835
      <div className={styles["pricing-toggle-wrapper"]}>
836
        <div
837
          {...tabListProps}
838
          ref={tabListRef}
839
          className={styles["pricing-toggle"]}
840
        >
841
          {Array.from(tabListState.collection).map((item) => (
842
            <PricingTab key={item.key} item={item} state={tabListState} />
132✔
843
          ))}
844
        </div>
845
      </div>
846
      <div
847
        {...tabPanelProps}
848
        ref={tabPanelRef}
849
        className={styles["pricing-overview"]}
850
      >
851
        {tabListState.selectedItem?.props.children}
852
      </div>
853
    </div>
854
  );
855
};
856

857
const PricingTab = (props: {
2✔
858
  state: TabListState<object>;
859
  item: { key: Parameters<typeof useTab>[0]["key"]; rendered: ReactNode };
860
}) => {
861
  const tabRef = useRef(null);
88✔
862
  const { tabProps } = useTab({ key: props.item.key }, props.state, tabRef);
88✔
863
  return (
864
    <div
865
      {...tabProps}
866
      ref={tabRef}
867
      className={
868
        props.state.selectedKey === props.item.key ? styles["is-selected"] : ""
88✔
869
      }
870
    >
871
      {props.item.rendered}
872
    </div>
873
  );
874
};
875

876
const VpnWordmark = (props: { children?: string }) => {
2✔
877
  return (
878
    <>
879
      &nbsp;
880
      <MozillaVpnWordmark alt={props.children ?? "Mozilla VPN"} />
×
881
    </>
882
  );
883
};
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