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

mozilla / fx-private-relay / 8b39734c-31ce-4dbf-9b05-171797c18cae

02 Oct 2025 04:03PM UTC coverage: 88.815% (-0.05%) from 88.863%
8b39734c-31ce-4dbf-9b05-171797c18cae

Pull #5924

circleci

vpremamozilla
MPP-4348: propagate UTM params from landing page to SubPlat and Accounts URLs
Pull Request #5924: MPP-4348: propagate UTM params from landing page to SubPlat and Accounts URLs

2932 of 3953 branches covered (74.17%)

Branch coverage included in aggregate %.

13 of 21 new or added lines in 5 files covered. (61.9%)

4 existing lines in 3 files now uncovered.

18086 of 19712 relevant lines covered (91.75%)

11.52 hits per line

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

85.85
/frontend/src/components/landing/PlanMatrix.tsx
1
import { useTab, useTabList, useTabPanel } from "react-aria";
3✔
2
import { ReactNode, useRef } from "react";
3✔
3
import Link from "next/link";
3✔
4
import {
5
  Item,
6
  TabListProps,
7
  TabListState,
8
  useTabListState,
9
} from "react-stately";
3✔
10
import styles from "./PlanMatrix.module.scss";
3✔
11
import {
12
  getBundlePrice,
13
  getBundleSubscribeLink,
14
  getPeriodicalPremiumPrice,
15
  getPeriodicalPremiumSubscribeLink,
16
  getPhonesPrice,
17
  getPhoneSubscribeLink,
18
  isBundleAvailableInCountry,
19
  isPeriodicalPremiumAvailableInCountry,
20
  isPhonesAvailableInCountry,
21
} from "../../functions/getPlan";
3✔
22
import { RuntimeData } from "../../hooks/api/types";
23
import { CheckIcon, MozillaVpnWordmark } from "../Icons";
3✔
24
import { getRuntimeConfig } from "../../config";
3✔
25
import { useGaEvent } from "../../hooks/gaEvent";
3✔
26
import { useGaViewPing } from "../../hooks/gaViewPing";
3✔
27
import { Plan, trackPlanPurchaseStart } from "../../functions/trackPurchase";
3✔
28
import { setCookie } from "../../functions/cookies";
3✔
29
import { useL10n } from "../../hooks/l10n";
3✔
30
import { Localized } from "../Localized";
3✔
31
import { LinkButton } from "../Button";
3✔
32
import { VisuallyHidden } from "../VisuallyHidden";
3✔
33
import { useIsLoggedIn } from "../../hooks/session";
3✔
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 = {
3✔
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 = {
3✔
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 = {
3✔
64
  ...premiumFeatures,
65
  "phone-mask": true,
66
};
67
const bundleFeatures: FeatureList = {
3✔
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) => {
7✔
80
  const l10n = useL10n();
7✔
81
  const freeButtonDesktopRef = useGaViewPing({
7✔
82
    category: "Sign In",
83
    label: "plan-matrix-free-cta-desktop",
84
  });
85
  const bundleButtonDesktopRef = useGaViewPing({
7✔
86
    category: "Purchase Bundle button",
87
    label: "plan-matrix-bundle-cta-desktop",
88
  });
89
  const freeButtonMobileRef = useGaViewPing({
7✔
90
    category: "Sign In",
91
    label: "plan-matrix-free-cta-mobile",
92
  });
93
  const bundleButtonMobileRef = useGaViewPing({
7✔
94
    category: "Purchase Bundle button",
95
    label: "plan-matrix-bundle-cta-mobile",
96
  });
97
  const gaEvent = useGaEvent();
7✔
98

99
  const countSignIn = (label: string) => {
7✔
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 isLoggedIn = useIsLoggedIn();
7✔
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}>
1✔
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}&auth_params=${
171
                    typeof window !== "undefined"
7!
172
                      ? encodeURIComponent(window.location.search.slice(1))
173
                      : ""
174
                  }`}
UNCOV
175
                  onClick={() => countSignIn("plan-matrix-free-cta-desktop")}
×
176
                  className={styles["primary-pick-button"]}
177
                  disabled={isLoggedIn === "logged-in"}
178
                >
179
                  {isLoggedIn === "logged-in"
7!
180
                    ? l10n.getString("plan-matrix-your-plan")
181
                    : l10n.getString("plan-matrix-get-relay-cta")}
182
                </LinkButton>
183
                {/*
184
                The <small> has space for price-related notices (e.g. "* billed
185
                annually"). When there is no such notice, we still want to leave
186
                space for it to prevent the page from jumping around; hence the
187
                empty <small>.
188
                */}
189
                <small>&nbsp;</small>
190
              </div>
191
            </div>
192
          </td>
193
          <td>
194
            {isPeriodicalPremiumAvailableInCountry(props.runtimeData) ? (
195
              <PricingToggle
6✔
196
                monthlyBilled={{
197
                  monthly_price: getPeriodicalPremiumPrice(
198
                    props.runtimeData,
199
                    "monthly",
200
                    l10n,
201
                  ),
202
                  subscribeLink: getPeriodicalPremiumSubscribeLink(
203
                    props.runtimeData,
204
                    "monthly",
205
                  ),
206
                  gaViewPing: {
207
                    category: "Purchase monthly Premium button",
208
                    label: "plan-matrix-premium-monthly-cta-desktop",
209
                  },
210
                  plan: {
211
                    plan: "premium",
212
                    billing_period: "monthly",
213
                  },
214
                }}
215
                yearlyBilled={{
216
                  monthly_price: getPeriodicalPremiumPrice(
217
                    props.runtimeData,
218
                    "yearly",
219
                    l10n,
220
                  ),
221
                  subscribeLink: getPeriodicalPremiumSubscribeLink(
222
                    props.runtimeData,
223
                    "yearly",
224
                  ),
225
                  gaViewPing: {
226
                    category: "Purchase yearly Premium button",
227
                    label: "plan-matrix-premium-yearly-cta-desktop",
228
                  },
229
                  plan: {
230
                    plan: "premium",
231
                    billing_period: "yearly",
232
                  },
233
                }}
234
              />
235
            ) : (
236
              <div className={`${styles.pricing} ${styles["single-price"]}`}>
237
                <div className={styles["pricing-overview"]}>
238
                  <span className={styles.price}>
239
                    {/* Clunky method to make sure the .pick-button is aligned
240
                        with the buttons for plans that do display a price */}
241
                    &nbsp;
242
                  </span>
243
                  <Link
244
                    href="/premium/waitlist"
245
                    className={styles["pick-button"]}
246
                  >
247
                    {l10n.getString("plan-matrix-join-waitlist")}
248
                  </Link>
249
                  {/*
250
                  The <small> has space for price-related notices (e.g. "* billed
251
                  annually"). When there is no such notice, we still want to leave
252
                  space for it to prevent the page from jumping around; hence the
253
                  empty <small>.
254
                  */}
255
                  <small>&nbsp;</small>
256
                </div>
257
              </div>
258
            )}
259
          </td>
260
          <td>
261
            {isPhonesAvailableInCountry(props.runtimeData) ? (
262
              <PricingToggle
6✔
263
                monthlyBilled={{
264
                  monthly_price: getPhonesPrice(
265
                    props.runtimeData,
266
                    "monthly",
267
                    l10n,
268
                  ),
269
                  subscribeLink: getPhoneSubscribeLink(
270
                    props.runtimeData,
271
                    "monthly",
272
                  ),
273
                  gaViewPing: {
274
                    category: "Purchase monthly Premium+phones button",
275
                    label: "plan-matrix-phone-monthly-cta-desktop",
276
                  },
277
                  plan: {
278
                    plan: "phones",
279
                    billing_period: "monthly",
280
                  },
281
                }}
282
                yearlyBilled={{
283
                  monthly_price: getPhonesPrice(
284
                    props.runtimeData,
285
                    "yearly",
286
                    l10n,
287
                  ),
288
                  subscribeLink: getPhoneSubscribeLink(
289
                    props.runtimeData,
290
                    "yearly",
291
                  ),
292
                  gaViewPing: {
293
                    category: "Purchase yearly Premium+phones button",
294
                    label: "plan-matrix-phone-yearly-cta-desktop",
295
                  },
296
                  plan: {
297
                    plan: "phones",
298
                    billing_period: "yearly",
299
                  },
300
                }}
301
              />
302
            ) : (
303
              <div className={`${styles.pricing} ${styles["single-price"]}`}>
304
                <div className={styles["pricing-overview"]}>
305
                  <span className={styles.price}>
306
                    {/* Clunky method to make sure the .pick-button is aligned
307
                        with the buttons for plans that do display a price */}
308
                    &nbsp;
309
                  </span>
310
                  <Link
311
                    href="/phone/waitlist"
312
                    className={styles["pick-button"]}
313
                  >
314
                    {l10n.getString("plan-matrix-join-waitlist")}
315
                  </Link>
316
                  <small>
317
                    {l10n.getString(
318
                      "plan-matrix-price-period-monthly-footnote-1",
319
                    )}
320
                  </small>
321
                </div>
322
              </div>
323
            )}
324
          </td>
325
          <td>
326
            {isBundleAvailableInCountry(props.runtimeData) ? (
327
              <div className={`${styles.pricing}`}>
1✔
328
                <div className={styles["pricing-toggle-wrapper"]}>
329
                  <p className={styles["discount-notice-wrapper"]}>
330
                    <Localized
331
                      id="plan-matrix-price-vpn-discount-promo"
332
                      vars={{
333
                        savings: "40%",
334
                      }}
335
                      elems={{
336
                        span: (
337
                          <span className={styles["discount-notice-bolded"]} />
338
                        ),
339
                      }}
340
                    >
341
                      <span className={styles["discount-notice-container"]} />
342
                    </Localized>
343
                  </p>
344
                </div>
345
                <div className={styles["pricing-overview"]}>
346
                  <span className={styles.price}>
347
                    {l10n.getString("plan-matrix-price-monthly-calculated", {
348
                      monthly_price: getBundlePrice(props.runtimeData, l10n),
349
                    })}
350
                  </span>
351
                  <a
352
                    ref={bundleButtonDesktopRef}
353
                    href={getBundleSubscribeLink(props.runtimeData)}
354
                    onClick={() =>
355
                      trackPlanPurchaseStart(
×
356
                        gaEvent,
357
                        { plan: "bundle" },
358
                        { label: "plan-matrix-bundle-cta-desktop" },
359
                      )
360
                    }
361
                    className={styles["pick-button"]}
362
                  >
363
                    {l10n.getString("plan-matrix-sign-up")}
364
                  </a>
365
                  <small>
366
                    {l10n.getString(
367
                      "plan-matrix-price-period-yearly-footnote-1",
368
                    )}
369
                  </small>
370
                </div>
371
              </div>
372
            ) : (
373
              <div className={`${styles.pricing} ${styles["single-price"]}`}>
374
                <div className={styles["pricing-overview"]}>
375
                  <span className={styles.price}>
376
                    {/* Clunky method to make sure the .pick-button is aligned
377
                        with the buttons for plans that do display a price */}
378
                    &nbsp;
379
                  </span>
380
                  <Link
381
                    href="/vpn-relay/waitlist"
382
                    className={styles["pick-button"]}
383
                  >
384
                    {l10n.getString("plan-matrix-join-waitlist")}
385
                  </Link>
386
                  <small>
387
                    {l10n.getString(
388
                      "plan-matrix-price-period-monthly-footnote-1",
389
                    )}
390
                  </small>
391
                </div>
392
              </div>
393
            )}
394
          </td>
395
        </tr>
396
      </tfoot>
397
    </table>
398
  );
399

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

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

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

675
type MobileFeatureListProps = {
676
  list: FeatureList;
677
};
678
const MobileFeatureList = (props: MobileFeatureListProps) => {
3✔
679
  const l10n = useL10n();
28✔
680

681
  const lis = Object.entries(props.list)
28✔
682
    .filter(
683
      ([_feature, availability]) =>
684
        typeof availability !== "boolean" || availability,
224✔
685
    )
686
    .map(([feature, availability]) => {
687
      const variables =
688
        typeof availability === "number"
168✔
689
          ? { mask_limit: availability }
690
          : undefined;
691
      const featureDescription =
692
        feature === "email-masks" && availability === Number.POSITIVE_INFINITY
168✔
693
          ? l10n.getString("plan-matrix-feature-list-email-masks-unlimited")
694
          : l10n.getString(`plan-matrix-feature-mobile-${feature}`, variables);
695

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

723
  return <ul className={styles["feature-list"]}>{lis}</ul>;
724
};
725

726
type AvailabilityListingProps = {
727
  availability: FeatureList[keyof FeatureList];
728
};
729
const AvailabilityListing = (props: AvailabilityListingProps) => {
3✔
730
  const l10n = useL10n();
392✔
731

732
  if (typeof props.availability === "number") {
392✔
733
    if (props.availability === Number.POSITIVE_INFINITY) {
56✔
734
      return <>{l10n.getString("plan-matrix-feature-count-unlimited")}</>;
735
    }
736
    return <>{props.availability}</>;
737
  }
738

739
  if (typeof props.availability === "boolean") {
336!
740
    return props.availability ? (
741
      <CheckIcon alt={l10n.getString("plan-matrix-feature-included")} />
280✔
742
    ) : (
743
      <VisuallyHidden>
744
        {l10n.getString("plan-matrix-feature-not-included")}
745
      </VisuallyHidden>
746
    );
747
  }
748

749
  return null as never;
×
750
};
751

752
type PricingToggleProps = {
753
  yearlyBilled: {
754
    monthly_price: string;
755
    subscribeLink: string;
756
    gaViewPing: Parameters<typeof useGaViewPing>[0];
757
    plan: Plan;
758
  };
759
  monthlyBilled: {
760
    monthly_price: string;
761
    subscribeLink: string;
762
    gaViewPing: Parameters<typeof useGaViewPing>[0];
763
    plan: Plan;
764
  };
765
};
766
const PricingToggle = (props: PricingToggleProps) => {
3✔
767
  const l10n = useL10n();
24✔
768
  const gaEvent = useGaEvent();
24✔
769
  const yearlyButtonRef = useGaViewPing(props.yearlyBilled.gaViewPing);
24✔
770
  const monthlyButtonRef = useGaViewPing(props.monthlyBilled.gaViewPing);
24✔
771

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

832
const PricingTabs = (props: TabListProps<object>) => {
3✔
833
  const tabListState = useTabListState(props);
72✔
834
  const tabListRef = useRef(null);
72✔
835
  const { tabListProps } = useTabList(props, tabListState, tabListRef);
72✔
836
  const tabPanelRef = useRef(null);
72✔
837
  const { tabPanelProps } = useTabPanel({}, tabListState, tabPanelRef);
72✔
838

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

863
const PricingTab = (props: {
3✔
864
  state: TabListState<object>;
865
  item: { key: Parameters<typeof useTab>[0]["key"]; rendered: ReactNode };
866
}) => {
867
  const tabRef = useRef(null);
96✔
868
  const { tabProps } = useTab({ key: props.item.key }, props.state, tabRef);
96✔
869
  return (
870
    <div
871
      {...tabProps}
872
      ref={tabRef}
873
      className={
874
        props.state.selectedKey === props.item.key ? styles["is-selected"] : ""
96✔
875
      }
876
    >
877
      {props.item.rendered}
878
    </div>
879
  );
880
};
881

882
const VpnWordmark = (props: { children?: string }) => {
3✔
883
  return (
884
    <>
885
      &nbsp;
886
      <MozillaVpnWordmark alt={props.children ?? "Mozilla VPN"} />
×
887
    </>
888
  );
889
};
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