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

mozilla / fx-private-relay / 7bf0ca0c-ba94-44bb-97b6-3c417f367ebc

29 May 2025 08:52PM CUT coverage: 85.63% (+0.05%) from 85.583%
7bf0ca0c-ba94-44bb-97b6-3c417f367ebc

push

circleci

web-flow
Merge pull request #5603 from mozilla/MPP-4165-megabundle-pricing-grid-2

MPP-4165 Megabundle Pricing Grid

2516 of 3650 branches covered (68.93%)

Branch coverage included in aggregate %.

64 of 70 new or added lines in 5 files covered. (91.43%)

1 existing line in 1 file now uncovered.

17661 of 19913 relevant lines covered (88.69%)

9.65 hits per line

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

88.89
/frontend/src/components/landing/PlanGrid.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 "./PlanGrid.module.scss";
3✔
11
import {
12
  getPeriodicalPremiumPrice,
13
  getPeriodicalPremiumYearlyPrice,
14
  getPeriodicalPremiumSubscribeLink,
15
  getPhonesPrice,
16
  getPhonesYearlyPrice,
17
  getPhoneSubscribeLink,
18
  isPeriodicalPremiumAvailableInCountry,
19
  isPhonesAvailableInCountry,
20
  getMegabundlePrice,
21
  getMegabundleYearlyPrice,
22
  isMegabundleAvailableInCountry,
23
  getMegabundleSubscribeLink,
24
  getIndividualBundlePrice,
25
  getBundleDiscountPercentage,
26
} from "../../functions/getPlan";
3✔
27
import { RuntimeData } from "../../hooks/api/runtimeData";
28
import {
29
  CheckIcon2,
30
  VpnIcon,
31
  PlusIcon2,
32
  MonitorIcon,
33
  RelayIcon,
34
} from "../Icons";
3✔
35
import { getRuntimeConfig } from "../../config";
3✔
36
import { useGaEvent } from "../../hooks/gaEvent";
3✔
37
import { useGaViewPing } from "../../hooks/gaViewPing";
3✔
38
import { Plan, trackPlanPurchaseStart } from "../../functions/trackPurchase";
3✔
39
import { setCookie } from "../../functions/cookies";
3✔
40
import { useL10n } from "../../hooks/l10n";
3✔
41
import { LinkButton } from "../Button";
3✔
42
import { useIsLoggedIn } from "../../hooks/session";
3✔
43
import { getLocale } from "../../functions/getLocale";
3✔
44

45
export type Props = {
46
  runtimeData: RuntimeData;
47
};
48

49
/**
50
 * Grid cards to compare and choose between the different plans available to the user.
51
 */
52
export const PlanGrid = (props: Props) => {
15✔
53
  const l10n = useL10n();
15✔
54

55
  const freeButtonDesktopRef = useGaViewPing({
15✔
56
    category: "Sign In",
57
    label: "plan-matrix-free-cta-desktop",
58
  });
59

60
  const gaEvent = useGaEvent();
15✔
61

62
  const countSignIn = (label: string) => {
15✔
NEW
63
    gaEvent({
×
64
      category: "Sign In",
65
      action: "Engage",
66
      label: label,
67
    });
NEW
68
    setCookie("user-sign-in", "true", { maxAgeInSeconds: 60 * 60 });
×
69
  };
70

71
  const isLoggedIn = useIsLoggedIn();
15✔
72

73
  const formatter = new Intl.NumberFormat(getLocale(l10n), {
15✔
74
    style: "currency",
75
    currency: "USD",
76
  });
77

78
  return (
79
    <div className={styles.content} data-testid="plan-grid-megabundle">
80
      <div className={styles.header}>
81
        <h2>
82
          <b>{l10n.getString("plan-grid-title")}</b>
83
        </h2>
84
        <p>{l10n.getString("plan-grid-body")}</p>
85
      </div>
86
      <section id="pricing-grid" className={styles.pricingPlans}>
87
        {isMegabundleAvailableInCountry(props.runtimeData) ? (
15✔
88
          <dl
89
            key={"megabundle"}
90
            className={styles.pricingCard}
91
            aria-label={l10n.getString("plan-grid-megabundle-title")}
92
          >
93
            <dt>
94
              <b>{l10n.getString("plan-grid-megabundle-title")}</b>
95
              <span className={styles.pricingCardLabel}>
96
                {l10n.getFragment("plan-grid-megabundle-label", {
97
                  vars: {
98
                    discountPercentage: getBundleDiscountPercentage(
99
                      props.runtimeData,
100
                      l10n,
101
                    ),
102
                  },
103
                })}
104
              </span>
105
              <p>{l10n.getString("plan-grid-megabundle-subtitle")}</p>
106
            </dt>
107
            <dd key={`megabundle-feature-1`}>
108
              <a
109
                key="bundle-vpn"
110
                className={styles.bundleItemLink}
111
                href={"https://www.mozilla.org/products/vpn/"}
112
              >
113
                <div className={styles.bundleTitle}>
114
                  <VpnIcon alt="" />
115
                  <b>{l10n.getString("plan-grid-megabundle-vpn-title")}</b>
116
                </div>
117
                {l10n.getString("plan-grid-megabundle-vpn-description")}
118
              </a>
119
            </dd>
120
            <dd key={"megabundle-feature-2"}>
121
              <Link
122
                key="megabundle-monitor"
123
                className={styles.bundleItemLink}
124
                href="https://monitor.mozilla.org/"
125
              >
126
                <div className={styles.bundleTitle}>
127
                  <MonitorIcon alt="" />
128
                  <b>{l10n.getString("plan-grid-megabundle-monitor-title")}</b>
129
                </div>
130
                {l10n.getString("plan-grid-megabundle-monitor-description")}
131
              </Link>
132
            </dd>
133
            <dd key={"megabundle-feature-3"}>
134
              <a
135
                key="megabundle-relay"
136
                className={styles.bundleItemLink}
137
                href="#"
138
                onClick={(e) => {
NEW
139
                  e.preventDefault();
×
140
                }}
141
              >
142
                <div className={styles.bundleTitle}>
143
                  <RelayIcon alt="" />
144
                  <b>{l10n.getString("plan-grid-megabundle-relay-title")}</b>
145
                </div>
146
                {l10n.getString("plan-grid-megabundle-relay-description")}
147
              </a>
148
            </dd>
149
            <dd className={styles.pricingCardCta}>
150
              <p id="pricingPlanBundle">
151
                <span className={styles.pricingCardSavings}>
152
                  {l10n.getString("plan-grid-megabundle-yearly", {
153
                    yearly_price: getMegabundleYearlyPrice(
154
                      props.runtimeData,
155
                      l10n,
156
                    ),
157
                  })}
158
                </span>
159
                <strong>
160
                  <s>{formatter.format(getIndividualBundlePrice("monthly"))}</s>
161
                  {l10n.getString("plan-grid-megabundle-monthly", {
162
                    price: getMegabundlePrice(props.runtimeData, l10n),
163
                  })}
164
                </strong>
165
              </p>
166
              <LinkButton
167
                href={getMegabundleSubscribeLink(props.runtimeData)}
168
                className={styles["megabundle-pick-button"]}
169
              >
170
                {l10n.getString("plan-grid-card-btn")}
171
              </LinkButton>
172
            </dd>
173
          </dl>
174
        ) : null}
175
        <dl
176
          key={"phone"}
177
          className={styles.pricingCard}
178
          aria-label={l10n.getString("plan-grid-premium-title")}
179
        >
180
          <dt>
181
            <b>{l10n.getString("plan-grid-premium-title")}</b>
182
            <p>{l10n.getString("plan-grid-phone-subtitle")}</p>
183
          </dt>
184
          <dd key={"phone-feature-plus"}>
185
            <span className={styles.plusNote}>
186
              <PlusIcon2 alt={l10n.getString("plan-grid-card-phone-plus")} />
187
              <b>{l10n.getString("plan-grid-card-phone-plus")}</b>
188
            </span>
189
          </dd>
190
          <dd key={`phone-feature-1`}>
191
            <CheckIcon2 alt={""} />
192
            <span>
193
              {l10n.getFragment("plan-grid-card-phone-item-one", {
194
                elems: { b: <b /> },
195
              })}
196
            </span>
197
          </dd>
198
          <dd className={styles.pricingCardCta}>
199
            {isPhonesAvailableInCountry(props.runtimeData) ? (
15✔
200
              <PricingToggle
201
                monthlyBilled={{
202
                  monthly_price: getPhonesPrice(
203
                    props.runtimeData,
204
                    "monthly",
205
                    l10n,
206
                  ),
207
                  subscribeLink: getPhoneSubscribeLink(
208
                    props.runtimeData,
209
                    "monthly",
210
                  ),
211
                  gaViewPing: {
212
                    category: "Purchase monthly Premium+phones button",
213
                    label: "plan-matrix-phone-monthly-cta-desktop",
214
                  },
215
                  plan: {
216
                    plan: "phones",
217
                    billing_period: "monthly",
218
                  },
219
                }}
220
                yearlyBilled={{
221
                  monthly_price: getPhonesPrice(
222
                    props.runtimeData,
223
                    "yearly",
224
                    l10n,
225
                  ),
226
                  yearly_price: getPhonesYearlyPrice(
227
                    props.runtimeData,
228
                    "yearly",
229
                    l10n,
230
                  ),
231
                  subscribeLink: getPhoneSubscribeLink(
232
                    props.runtimeData,
233
                    "yearly",
234
                  ),
235
                  gaViewPing: {
236
                    category: "Purchase yearly Premium+phones button",
237
                    label: "plan-matrix-phone-yearly-cta-desktop",
238
                  },
239
                  plan: {
240
                    plan: "phones",
241
                    billing_period: "yearly",
242
                  },
243
                }}
244
              />
245
            ) : null}
246
          </dd>
247
        </dl>
248

249
        <dl
250
          key={"premium"}
251
          className={styles.pricingCard}
252
          aria-label={l10n.getString("plan-grid-premium-title")}
253
        >
254
          <dt>
255
            <b>{l10n.getString("plan-grid-premium-title")}</b>
256
            <p>{l10n.getString("plan-grid-premium-subtitle")}</p>
257
          </dt>
258
          <dd key={"premium-feature-plus"}>
259
            <span className={styles.plusNote}>
260
              <PlusIcon2 alt={l10n.getString("plan-grid-card-premium-plus")} />
261
              <b>{l10n.getString("plan-grid-card-premium-plus")}</b>
262
            </span>
263
          </dd>
264
          <dd key={`premium-feature-1`}>
265
            <CheckIcon2 alt={""} />
266
            <span>
267
              {l10n.getFragment("plan-grid-card-premium-item-one", {
268
                elems: { b: <b /> },
269
              })}
270
            </span>
271
          </dd>
272
          <dd key={`premium-feature-2`}>
273
            <CheckIcon2 alt={""} />
274
            <span>
275
              {l10n.getFragment("plan-grid-card-premium-item-two", {
276
                elems: { b: <b /> },
277
              })}
278
            </span>
279
          </dd>
280
          <dd key={`premium-feature-3`}>
281
            <CheckIcon2 alt={""} />
282
            <span>
283
              {l10n.getFragment("plan-grid-card-premium-item-three", {
284
                elems: { b: <b /> },
285
              })}
286
            </span>
287
          </dd>
288
          <dd key={`premium-feature-4`}>
289
            <CheckIcon2 alt={""} />
290
            <span>
291
              {l10n.getFragment("plan-grid-card-premium-item-four", {
292
                elems: { b: <b /> },
293
              })}
294
            </span>
295
          </dd>
296
          <dd className={styles.pricingCardCta}>
297
            {isPeriodicalPremiumAvailableInCountry(props.runtimeData) ? (
15✔
298
              <PricingToggle
299
                monthlyBilled={{
300
                  monthly_price: getPeriodicalPremiumPrice(
301
                    props.runtimeData,
302
                    "monthly",
303
                    l10n,
304
                  ),
305
                  subscribeLink: getPeriodicalPremiumSubscribeLink(
306
                    props.runtimeData,
307
                    "monthly",
308
                  ),
309
                  gaViewPing: {
310
                    category: "Purchase monthly Premium button",
311
                    label: "plan-matrix-premium-monthly-cta-desktop",
312
                  },
313
                  plan: {
314
                    plan: "premium",
315
                    billing_period: "monthly",
316
                  },
317
                }}
318
                yearlyBilled={{
319
                  monthly_price: getPeriodicalPremiumPrice(
320
                    props.runtimeData,
321
                    "yearly",
322
                    l10n,
323
                  ),
324
                  yearly_price: getPeriodicalPremiumYearlyPrice(
325
                    props.runtimeData,
326
                    "yearly",
327
                    l10n,
328
                  ),
329
                  subscribeLink: getPeriodicalPremiumSubscribeLink(
330
                    props.runtimeData,
331
                    "yearly",
332
                  ),
333
                  gaViewPing: {
334
                    category: "Purchase yearly Premium button",
335
                    label: "plan-matrix-premium-yearly-cta-desktop",
336
                  },
337
                  plan: {
338
                    plan: "premium",
339
                    billing_period: "yearly",
340
                  },
341
                }}
342
              />
343
            ) : null}
344
          </dd>
345
        </dl>
346

347
        <dl
348
          key={"free"}
349
          className={styles.pricingCard}
350
          aria-label={l10n.getString("plan-grid-free-title")}
351
        >
352
          <dt>
353
            <b>{l10n.getString("plan-grid-free-title")}</b>
354
            <p>{l10n.getString("plan-matrix-heading-plan-free")}</p>
355
          </dt>
356
          <dd key={`free-feature-1`}>
357
            <CheckIcon2 alt={""} />
358
            <span>
359
              {l10n.getFragment("plan-grid-card-free-item-one", {
360
                vars: {
361
                  mask_limit: 5,
362
                },
363
                elems: { b: <b /> },
364
              })}
365
            </span>
366
          </dd>
367
          <dd key={`free-feature-2`}>
368
            <CheckIcon2 alt={""} />
369
            <span>
370
              {l10n.getFragment("plan-grid-card-free-item-two", {
371
                elems: { b: <b /> },
372
              })}
373
            </span>
374
          </dd>
375
          <dd key={`free-feature-3`}>
376
            <CheckIcon2 alt={""} />
377
            <span>
378
              {l10n.getFragment("plan-grid-card-free-item-three", {
379
                elems: { b: <b /> },
380
              })}
381
            </span>
382
          </dd>
383
          <dd className={styles.pricingCardCta}>
384
            <p>
385
              <strong>{l10n.getString("plan-matrix-price-free")}</strong>
386
            </p>
387
            <LinkButton
388
              ref={freeButtonDesktopRef}
389
              href={getRuntimeConfig().fxaLoginUrl}
NEW
390
              onClick={() => countSignIn("plan-matrix-free-cta-desktop")}
×
391
              className={styles["pick-button"]}
392
              disabled={isLoggedIn === "logged-in"}
393
            >
394
              {isLoggedIn === "logged-in"
15✔
395
                ? l10n.getString("plan-matrix-your-plan")
396
                : l10n.getString("plan-grid-card-btn")}
397
            </LinkButton>
398
          </dd>
399
        </dl>
400
      </section>
401
    </div>
402
  );
403
};
404

405
type PricingToggleProps = {
406
  yearlyBilled: {
407
    monthly_price: string;
408
    yearly_price: string;
409
    subscribeLink: string;
410
    gaViewPing: Parameters<typeof useGaViewPing>[0];
411
    plan: Plan;
412
  };
413
  monthlyBilled: {
414
    monthly_price: string;
415
    subscribeLink: string;
416
    gaViewPing: Parameters<typeof useGaViewPing>[0];
417
    plan: Plan;
418
  };
419
};
420
const PricingToggle = (props: PricingToggleProps) => {
3✔
421
  const l10n = useL10n();
27✔
422
  const gaEvent = useGaEvent();
27✔
423
  const yearlyButtonRef = useGaViewPing(props.yearlyBilled.gaViewPing);
27✔
424
  const monthlyButtonRef = useGaViewPing(props.monthlyBilled.gaViewPing);
27✔
425

426
  return (
427
    <PricingTabs defaultSelectedKey="yearly">
428
      <Item
429
        key="yearly"
430
        title={l10n.getString("plan-matrix-price-period-yearly")}
431
      >
432
        <div className={styles["price-text"]}>
433
          <small>
434
            {l10n.getString("plan-matrix-price-yearly-calculated", {
435
              yearly_price: props.yearlyBilled.yearly_price,
436
            })}
437
          </small>
438
          <span className={styles.price}>
439
            {l10n.getString("plan-matrix-price-monthly-calculated", {
440
              monthly_price: props.yearlyBilled.monthly_price,
441
            })}
442
          </span>
443
        </div>
444
        <a
445
          ref={yearlyButtonRef}
446
          href={props.yearlyBilled.subscribeLink}
447
          onClick={() =>
NEW
448
            trackPlanPurchaseStart(gaEvent, props.yearlyBilled.plan, {
×
449
              label: props.yearlyBilled.gaViewPing?.label,
450
            })
451
          }
452
          tabIndex={0}
453
          className={styles["pick-button"]}
454
        >
455
          {l10n.getString("plan-grid-card-btn")}
456
        </a>
457
      </Item>
458
      <Item
459
        key="monthly"
460
        title={l10n.getString("plan-matrix-price-period-monthly")}
461
      >
462
        <div className={styles["price-text"]}>
463
          <small>{l10n.getString("plan-grid-billed-monthly")}</small>
464
          <span className={styles.price}>
465
            {l10n.getString("plan-matrix-price-monthly-calculated", {
466
              monthly_price: props.monthlyBilled.monthly_price,
467
            })}
468
          </span>
469
        </div>
470
        <a
471
          ref={monthlyButtonRef}
472
          href={props.monthlyBilled.subscribeLink}
473
          onClick={() =>
NEW
474
            trackPlanPurchaseStart(gaEvent, props.monthlyBilled.plan, {
×
475
              label: props.monthlyBilled.gaViewPing?.label,
476
            })
477
          }
478
          tabIndex={0}
479
          className={styles["pick-button"]}
480
        >
481
          {l10n.getString("plan-grid-card-btn")}
482
        </a>
483
      </Item>
484
    </PricingTabs>
485
  );
486
};
487

488
const PricingTabs = (props: TabListProps<object>) => {
3✔
489
  const tabListState = useTabListState(props);
81✔
490
  const tabListRef = useRef(null);
81✔
491
  const { tabListProps } = useTabList(props, tabListState, tabListRef);
81✔
492
  const tabPanelRef = useRef(null);
81✔
493
  const { tabPanelProps } = useTabPanel({}, tabListState, tabPanelRef);
81✔
494

495
  return (
496
    <div className={styles.pricing}>
497
      <div className={styles["pricing-toggle-wrapper"]}>
498
        <div
499
          {...tabListProps}
500
          ref={tabListRef}
501
          className={styles["pricing-toggle"]}
502
        >
503
          {Array.from(tabListState.collection).map((item) => (
504
            <PricingTab key={item.key} item={item} state={tabListState} />
162✔
505
          ))}
506
        </div>
507
      </div>
508
      <div
509
        {...tabPanelProps}
510
        ref={tabPanelRef}
511
        className={styles["pricing-overview"]}
512
      >
513
        {tabListState.selectedItem?.props.children}
514
      </div>
515
    </div>
516
  );
517
};
518

519
const PricingTab = (props: {
3✔
520
  state: TabListState<object>;
521
  item: { key: Parameters<typeof useTab>[0]["key"]; rendered: ReactNode };
522
}) => {
523
  const tabRef = useRef(null);
108✔
524
  const { tabProps } = useTab({ key: props.item.key }, props.state, tabRef);
108✔
525
  return (
526
    <div
527
      {...tabProps}
528
      ref={tabRef}
529
      className={
530
        props.state.selectedKey === props.item.key ? styles["is-selected"] : ""
108✔
531
      }
532
    >
533
      {props.item.rendered}
534
    </div>
535
  );
536
};
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