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

mozilla / fx-private-relay / b4f21704-cf8f-4dbc-b7b2-8d24d2c4b9eb

05 Aug 2024 07:01PM UTC coverage: 85.416% (-0.02%) from 85.431%
b4f21704-cf8f-4dbc-b7b2-8d24d2c4b9eb

push

circleci

web-flow
Merge pull request #4928 from mozilla/dependabot/npm_and_yarn/eslint-cc3139cf36

Bump the eslint group with 2 updates

4101 of 5252 branches covered (78.08%)

Branch coverage included in aggregate %.

2 of 8 new or added lines in 3 files covered. (25.0%)

1 existing line in 1 file now uncovered.

15971 of 18247 relevant lines covered (87.53%)

10.43 hits per line

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

70.23
/frontend/src/pages/accounts/profile.page.tsx
1
import type { NextPage } from "next";
2
import {
3
  forwardRef,
4
  HTMLAttributes,
5
  ReactNode,
6
  RefObject,
7
  useRef,
8
  useState,
9
} from "react";
1✔
10
import {
11
  FocusScope,
12
  mergeProps,
13
  useButton,
14
  useOverlay,
15
  useOverlayPosition,
16
  useOverlayTrigger,
17
  useTooltip,
18
  useTooltipTrigger,
19
} from "react-aria";
1✔
20
import { useMenuTriggerState, useTooltipTriggerState } from "react-stately";
1✔
21
import { toast } from "react-toastify";
1✔
22
import styles from "./profile.module.scss";
1✔
23
import UpsellBannerUs from "./images/upsell-banner-us.svg";
1✔
24
import UpsellBannerNonUs from "./images/upsell-banner-nonus.svg";
1✔
25
import { CheckBadgeIcon, LockIcon, PencilIcon } from "../../components/Icons";
1✔
26
import { Layout } from "../../components/layout/Layout";
1✔
27
import { useProfiles } from "../../hooks/api/profile";
1✔
28
import {
29
  AliasData,
30
  getAllAliases,
31
  getFullAddress,
32
  useAliases,
33
} from "../../hooks/api/aliases";
1✔
34
import { useGaEvent } from "../../hooks/gaEvent";
1✔
35
import { useUsers } from "../../hooks/api/user";
1✔
36
import { AliasList } from "../../components/dashboard/aliases/AliasList";
1✔
37
import { ProfileBanners } from "../../components/dashboard/ProfileBanners";
1✔
38
import { LinkButton } from "../../components/Button";
1✔
39
import { useRuntimeData } from "../../hooks/api/runtimeData";
1✔
40
import {
41
  isPeriodicalPremiumAvailableInCountry,
42
  isPhonesAvailableInCountry,
43
} from "../../functions/getPlan";
1✔
44
import { useGaViewPing } from "../../hooks/gaViewPing";
1✔
45
import { PremiumOnboarding } from "../../components/dashboard/PremiumOnboarding";
1✔
46
import { Onboarding } from "../../components/dashboard/Onboarding";
1✔
47
import { getRuntimeConfig } from "../../config";
1✔
48
import { Tips } from "../../components/dashboard/tips/Tips";
1✔
49
import { getLocale } from "../../functions/getLocale";
1✔
50
import { AddonData } from "../../components/dashboard/AddonData";
1✔
51
import { useAddonData } from "../../hooks/addon";
1✔
52
import { CloseIcon } from "../../components/Icons";
53
import Image from "../../components/Image";
1✔
54
import { isFlagActive } from "../../functions/waffle";
1✔
55
import { DashboardSwitcher } from "../../components/layout/navigation/DashboardSwitcher";
1✔
56
import { usePurchaseTracker } from "../../hooks/purchaseTracker";
1✔
57
import { useL10n } from "../../hooks/l10n";
1✔
58
import { Localized } from "../../components/Localized";
1✔
59
import { clearCookie, getCookie, setCookie } from "../../functions/cookies";
1✔
60
import { SubdomainInfoTooltip } from "../../components/dashboard/subdomain/SubdomainInfoTooltip";
1✔
61
import Link from "next/link";
1✔
62
import { FreeOnboarding } from "../../components/dashboard/FreeOnboarding";
1✔
63
import Confetti from "react-confetti";
1✔
64
import { useRouter } from "next/router";
1✔
65
import { CornerNotification } from "../../components/dashboard/CornerNotification";
1✔
66

67
const Profile: NextPage = () => {
1✔
68
  const runtimeData = useRuntimeData();
55✔
69
  const profileData = useProfiles();
55✔
70
  const userData = useUsers();
55✔
71
  const aliasData = useAliases();
55✔
72
  const addonData = useAddonData();
55✔
73
  const router = useRouter();
55✔
74
  const l10n = useL10n();
55✔
75
  const setCustomDomainLinkRef = useGaViewPing({
55✔
76
    category: "Purchase Button",
77
    label: "profile-set-custom-domain",
78
  });
79
  const gaEvent = useGaEvent();
55✔
80
  const hash = getCookie("profile-location-hash");
54✔
81
  if (hash) {
54!
82
    document.location.hash = hash;
×
83
    clearCookie("profile-location-hash");
×
84
  }
85
  usePurchaseTracker(profileData.data?.[0]);
54✔
86
  const [modalOpened, setModalOpenedState] = useState(false);
54✔
87

88
  if (!userData.isValidating && userData.error) {
54!
89
    if (document.location.hash) {
×
90
      setCookie("profile-location-hash", document.location.hash);
×
91
    }
92
    // Add url params to auth_params so django-allauth will send them to FXA
93
    const originalUrlParams = document.location.search.replace("?", "");
×
94
    const fxaLoginWithAuthParams =
95
      getRuntimeConfig().fxaLoginUrl +
×
96
      "&auth_params=" +
97
      encodeURIComponent(originalUrlParams);
98
    document.location.assign(fxaLoginWithAuthParams);
×
99
  }
100

101
  const profile = profileData.data?.[0];
54✔
102
  const user = userData.data?.[0];
54✔
103
  if (
54!
104
    !runtimeData.data ||
324✔
105
    !profile ||
106
    !user ||
107
    !router ||
108
    !aliasData.randomAliasData.data ||
109
    !aliasData.customAliasData.data
110
  ) {
111
    // TODO: Show a loading spinner?
112
    return null;
×
113
  }
114

115
  const setCustomSubdomain = async (customSubdomain: string) => {
54✔
116
    const response = await profileData.setSubdomain(customSubdomain);
×
117
    if (!response.ok) {
×
118
      toast(
×
119
        l10n.getString("error-subdomain-not-available-2", {
120
          unavailable_subdomain: customSubdomain,
121
        }),
122
        { type: "error" },
123
      );
124
    }
125
    addonData.sendEvent("subdomainClaimed", { subdomain: customSubdomain });
×
126
  };
127

128
  const allAliases = getAllAliases(
54✔
129
    aliasData.randomAliasData.data,
130
    aliasData.customAliasData.data,
131
  );
132

133
  // premium user onboarding experience
134
  if (
54✔
135
    profile.has_premium &&
81✔
136
    profile.onboarding_state < getRuntimeConfig().maxOnboardingAvailable
137
  ) {
138
    const onNextStep = (step: number) => {
8✔
139
      profileData.update(profile.id, {
2✔
140
        onboarding_state: step,
141
      });
142
    };
143

144
    return (
145
      <>
146
        <AddonData
147
          aliases={allAliases}
148
          profile={profile}
149
          runtimeData={runtimeData.data}
150
          totalBlockedEmails={profile.emails_blocked}
151
          totalForwardedEmails={profile.emails_forwarded}
152
          totalEmailTrackersRemoved={profile.level_one_trackers_blocked}
153
        />
154
        <Layout runtimeData={runtimeData.data}>
155
          {isPhonesAvailableInCountry(runtimeData.data) ? (
8!
156
            <DashboardSwitcher />
157
          ) : null}
158
          <PremiumOnboarding
159
            profile={profile}
160
            onNextStep={onNextStep}
161
            onPickSubdomain={setCustomSubdomain}
162
          />
163
        </Layout>
164
      </>
165
    );
166
  }
167

168
  const freeMaskLimit = getRuntimeConfig().maxFreeAliases;
46✔
169
  const freeMaskLimitReached =
170
    allAliases.length >= freeMaskLimit && !profile.has_premium;
46✔
171

172
  const createAlias = async (
46✔
173
    options:
174
      | { mask_type: "random" }
175
      | { mask_type: "custom"; address: string; blockPromotionals: boolean },
176
    setAliasGeneratedState?: (flag: boolean) => void,
177
  ) => {
178
    try {
1✔
179
      const response = await aliasData.create(options);
1✔
180
      if (!response.ok) {
1!
181
        throw new Error(
×
182
          "Immediately caught to land in the same code path as failed requests.",
183
        );
184
      }
185
      if (setAliasGeneratedState) {
1!
186
        setAliasGeneratedState(true);
×
187
      }
188
      addonData.sendEvent("aliasListUpdate");
1✔
189
    } catch (_error) {
190
      // TODO: Refactor CustomAddressGenerationModal to remove the setAliasGeneratedState callback, and instead use a try catch block.
NEW
191
      if (setAliasGeneratedState) {
×
NEW
192
        setAliasGeneratedState(false);
×
193
      } else {
NEW
194
        toast(l10n.getString("error-mask-create-failed"), { type: "error" });
×
195
      }
196
      // This is so we can catch the error when calling createAlias asynchronously and apply
197
      // more logic to handle when generating a mask fails.
198
      return Promise.reject("Mask generation failed");
×
199
    }
200
  };
201

202
  const updateAlias = async (
46✔
203
    alias: AliasData,
204
    updatedFields: Partial<AliasData>,
205
  ) => {
206
    try {
1✔
207
      const response = await aliasData.update(alias, updatedFields);
1✔
208
      if (!response.ok) {
1!
209
        throw new Error(
×
210
          "Immediately caught to land in the same code path as failed requests.",
211
        );
212
      }
213
    } catch (_error) {
214
      toast(
1✔
215
        l10n.getString("error-mask-update-failed", {
216
          alias: getFullAddress(alias),
217
        }),
218
        { type: "error" },
219
      );
220
    }
221
  };
222

223
  const deleteAlias = async (alias: AliasData) => {
46✔
224
    try {
1✔
225
      const response = await aliasData.delete(alias);
1✔
226
      if (!response.ok) {
1!
227
        throw new Error(
×
228
          "Immediately caught to land in the same code path as failed requests.",
229
        );
230
      }
231
      addonData.sendEvent("aliasListUpdate");
1✔
232
    } catch (_error: unknown) {
233
      toast(
×
234
        l10n.getString("error-mask-delete-failed", {
235
          alias: getFullAddress(alias),
236
        }),
237
        { type: "error" },
238
      );
239
    }
240
  };
241

242
  // We pull UTM parameters from query
243
  const {
244
    utm_campaign = "",
46✔
245
    utm_medium = "",
46✔
246
    utm_source = "",
46✔
247
  } = router.query || {};
46✔
248
  const isFreeUserOnboardingActive = isFlagActive(
46✔
249
    runtimeData.data,
250
    "free_user_onboarding",
251
  );
252

253
  // We validate UTM parameters to ensure they match the expected values for the onboarding campaign
254
  const isValidUtmParameters =
255
    utm_campaign === "relay-onboarding" &&
46!
256
    utm_source === "relay-onboarding" &&
257
    utm_medium === "email";
258

259
  // Determine if the user is part of the target audience for onboarding
260
  // This checks if the user does not have a premium account and has not completed all onboarding steps
261
  const isOnboarding =
262
    profile.onboarding_free_state <
46✔
263
    getRuntimeConfig().maxOnboardingFreeAvailable;
264
  const isTargetAudience = !profile.has_premium && isOnboarding;
46✔
265

266
  // Conditions: onboarding is active, UTM parameters are valid OR the user has less than or equal to
267
  // 2 masks (if in onboarding process, up to 3), and the user is part of the target audience
268
  if (
46!
269
    isFreeUserOnboardingActive &&
46!
270
    (isValidUtmParameters ||
271
      allAliases.length <= 2 ||
272
      (profile.onboarding_free_state > 0 && allAliases.length <= 3)) &&
273
    isTargetAudience
274
  ) {
275
    const onNextStep = (step: number) => {
×
276
      profileData.update(profile.id, {
×
277
        onboarding_free_state: step,
278
      });
279
    };
280

281
    return (
282
      <>
283
        <AddonData
284
          aliases={allAliases}
285
          profile={profile}
286
          runtimeData={runtimeData.data}
287
          totalBlockedEmails={profile.emails_blocked}
288
          totalForwardedEmails={profile.emails_forwarded}
289
          totalEmailTrackersRemoved={profile.level_one_trackers_blocked}
290
        />
291
        <Layout runtimeData={runtimeData.data}>
292
          {isPhonesAvailableInCountry(runtimeData.data) ? (
×
293
            <DashboardSwitcher />
294
          ) : null}
295
          <FreeOnboarding
296
            profile={profile}
297
            onNextStep={onNextStep}
298
            onPickSubdomain={setCustomSubdomain}
299
            aliases={allAliases}
300
            generateNewMask={createAlias}
301
            hasReachedFreeMaskLimit={freeMaskLimitReached}
302
            user={user}
303
            runtimeData={runtimeData.data}
304
            onUpdate={updateAlias}
305
            hasAtleastOneMask={allAliases.length >= 1}
306
          />
307
        </Layout>
308
      </>
309
    );
310
  }
311

312
  const subdomainMessage =
313
    typeof profile.subdomain === "string" ? (
314
      <>
2✔
315
        <span>{l10n.getString("profile-label-custom-domain")}</span>
316
        <span className={styles["profile-registered-domain-value"]}>
317
          @{profile.subdomain}.{getRuntimeConfig().mozmailDomain}
318
        </span>
319
      </>
320
    ) : profile.has_premium ? (
321
      <a className={styles["open-button"]} href="#mpp-choose-subdomain">
17✔
322
        {l10n.getString("profile-label-set-your-custom-domain-free-user")}
323
      </a>
324
    ) : (
325
      <Link
326
        className={styles["open-button"]}
327
        href={"/premium#pricing"}
328
        ref={setCustomDomainLinkRef}
329
        onClick={() => {
330
          gaEvent({
×
331
            category: "Purchase Button",
332
            action: "Engage",
333
            label: "profile-set-custom-domain",
334
          });
335
        }}
336
      >
337
        {l10n.getString("profile-label-set-your-custom-domain-free-user")}
338
      </Link>
339
    );
340

341
  const numberFormatter = new Intl.NumberFormat(getLocale(l10n), {
46✔
342
    notation: "compact",
343
    compactDisplay: "short",
344
  });
345

346
  type TooltipProps = {
347
    children: ReactNode;
348
  };
349

350
  const MaxedMasksTooltip = (props: TooltipProps) => {
46✔
351
    const l10n = useL10n();
8✔
352
    const triggerState = useTooltipTriggerState({ delay: 0 });
8✔
353
    const triggerRef = useRef<HTMLSpanElement>(null);
8✔
354
    const tooltipTrigger = useTooltipTrigger({}, triggerState, triggerRef);
8✔
355
    const { tooltipProps } = useTooltip({}, triggerState);
8✔
356

357
    return (
358
      <div className={styles["stat-wrapper"]}>
359
        <span
360
          ref={triggerRef}
361
          {...tooltipTrigger.triggerProps}
362
          className={`${styles.stat} ${styles["forwarded-stat"]}`}
363
        >
364
          {props.children}
365
        </span>
366
        {triggerState.isOpen && (
8✔
367
          <div
368
            {...mergeProps(tooltipTrigger.tooltipProps, tooltipProps)}
369
            className={styles.tooltip}
370
          >
371
            <p>
372
              {l10n.getString("profile-maxed-aliases-tooltip", {
373
                limit: freeMaskLimit,
374
              })}
375
            </p>
376
          </div>
377
        )}
378
      </div>
379
    );
380
  };
381

382
  // Show stats for free users and premium users
383
  const stats = (
384
    <section className={styles.header}>
385
      <div className={styles["header-wrapper"]}>
386
        <div className={styles["user-details"]}>
387
          <Localized
388
            id="profile-label-welcome-html"
389
            vars={{
390
              email: user.email,
391
            }}
392
            elems={{
393
              span: <span className={styles.lead} />,
394
            }}
395
          >
396
            <span className={styles.greeting} />
397
          </Localized>
398
          <strong className={styles.subdomain}>
399
            {/* render check badge if subdomain is set and user has premium
400
            render pencil icon if subdomain is not set but user has premium
401
            render lock icon by default */}
402
            {typeof profile.subdomain === "string" && profile.has_premium ? (
48✔
403
              <CheckBadgeIcon alt="" />
2✔
404
            ) : typeof profile.subdomain !== "string" && profile.has_premium ? (
88✔
405
              <PencilIcon alt="" className={styles["pencil-icon"]} />
17✔
406
            ) : (
407
              <LockIcon alt="" className={styles["lock-icon"]} />
408
            )}
409
            {subdomainMessage}
410
            <SubdomainInfoTooltip hasPremium={profile.has_premium} />
411
          </strong>
412
        </div>
413
        <dl className={styles["account-stats"]}>
414
          <div className={styles.stat}>
415
            <dt className={styles.label}>
416
              {l10n.getString("profile-stat-label-aliases-used-2")}
417
            </dt>
418
            {/* If premium is available in the user's country and 
419
            the user has reached their free mask limit and 
420
            they are a free user, show the maxed masks tooltip */}
421
            {isPeriodicalPremiumAvailableInCountry(runtimeData.data) &&
90✔
422
            freeMaskLimitReached ? (
423
              <dd className={`${styles.value} ${styles.maxed}`}>
7✔
424
                <MaxedMasksTooltip>
425
                  {numberFormatter.format(allAliases.length)}
426
                </MaxedMasksTooltip>
427
              </dd>
428
            ) : (
429
              <dd className={`${styles.value}`}>
430
                {numberFormatter.format(allAliases.length)}
431
              </dd>
432
            )}
433
          </div>
434
          <div className={styles.stat}>
435
            <dt className={styles.label}>
436
              {l10n.getString("profile-stat-label-blocked")}
437
            </dt>
438
            <dd className={styles.value}>
439
              {numberFormatter.format(profile.emails_blocked)}
440
            </dd>
441
          </div>
442
          <div className={styles.stat}>
443
            <dt className={styles.label}>
444
              {l10n.getString("profile-stat-label-forwarded")}
445
            </dt>
446
            <dd className={styles.value}>
447
              {numberFormatter.format(profile.emails_forwarded)}
448
            </dd>
449
          </div>
450
          {/*
451
            Only show tracker blocking stats if the back-end provides them:
452
          */}
453
          {isFlagActive(runtimeData.data, "tracker_removal") &&
46!
454
            typeof profile.level_one_trackers_blocked === "number" && (
455
              <div className={styles.stat}>
456
                <dt className={styles.label}>
457
                  {l10n.getString("profile-stat-label-trackers-removed")}
458
                </dt>
459
                <dd className={styles.value}>
460
                  {numberFormatter.format(profile.level_one_trackers_blocked)}
461
                  <StatExplainer>
462
                    <p>
463
                      {l10n.getString(
464
                        "profile-stat-label-trackers-learn-more-part1",
465
                      )}
466
                    </p>
467
                    <p>
468
                      {l10n.getString(
469
                        "profile-stat-label-trackers-learn-more-part2-2",
470
                      )}
471
                    </p>
472
                  </StatExplainer>
473
                </dd>
474
              </div>
475
            )}
476
        </dl>
477
      </div>
478
    </section>
479
  );
480

481
  const banners = (
482
    <section className={styles["banners-wrapper"]}>
483
      <ProfileBanners
484
        profile={profile}
485
        user={user}
486
        onCreateSubdomain={setCustomSubdomain}
487
        runtimeData={runtimeData.data}
488
        aliases={allAliases}
489
      />
490
    </section>
491
  );
492
  const topBanners = allAliases.length > 0 ? banners : null;
46✔
493
  const bottomBanners = allAliases.length === 0 ? banners : null;
46✔
494

495
  // Render the upsell banner when a user has reached the free mask limit
496
  const UpsellBanner = () => (
497
    <div className={styles["upsell-banner"]}>
498
      <div className={styles["upsell-banner-wrapper"]}>
499
        <div className={styles["upsell-banner-content"]}>
500
          <p className={styles["upsell-banner-header"]}>
501
            {isPhonesAvailableInCountry(runtimeData.data)
7✔
502
              ? l10n.getString("profile-maxed-aliases-with-phone-header")
503
              : l10n.getString("profile-maxed-aliases-without-phone-header")}
504
          </p>
505
          <p className={styles["upsell-banner-description"]}>
506
            {l10n.getString(
507
              isPhonesAvailableInCountry(runtimeData.data)
7✔
508
                ? "profile-maxed-aliases-with-phone-description"
509
                : "profile-maxed-aliases-without-phone-description",
510
              {
511
                limit: freeMaskLimit,
512
              },
513
            )}
514
          </p>
515
          <LinkButton
516
            href="/premium#pricing"
517
            ref={useGaViewPing({
518
              category: "Purchase Button",
519
              label: "upgrade-premium-header-mask-limit",
520
            })}
521
            onClick={() => {
522
              gaEvent({
×
523
                category: "Purchase Button",
524
                action: "Engage",
525
                label: "upgrade-premium-header-mask-limit",
526
              });
527
            }}
528
          >
529
            {l10n.getString("profile-maxed-aliases-cta")}
530
          </LinkButton>
531
        </div>
532
        <Image
533
          className={styles["upsell-banner-image"]}
534
          src={
535
            isPhonesAvailableInCountry(runtimeData.data)
7✔
536
              ? UpsellBannerUs
537
              : UpsellBannerNonUs
538
          }
539
          alt=""
540
        />
541
      </div>
542
    </div>
543
  );
544

545
  return (
546
    <>
547
      <AddonData
548
        aliases={allAliases}
549
        profile={profile}
550
        runtimeData={runtimeData.data}
551
        totalBlockedEmails={profile.emails_blocked}
552
        totalForwardedEmails={profile.emails_forwarded}
553
        totalEmailTrackersRemoved={profile.level_one_trackers_blocked}
554
      />
555
      {/* Show confetti animation when user completes last step. */}
556
      {isFlagActive(runtimeData.data, "free_user_onboarding") &&
46!
557
        !profile.has_premium &&
558
        profile.onboarding_free_state === 4 && (
559
          <Confetti
560
            tweenDuration={5000}
561
            gravity={0.2}
562
            recycle={false}
563
            onConfettiComplete={() => {
564
              // Update onboarding step to 4 - prevents animation from displaying again.
565
              profileData.update(profile.id, {
×
566
                onboarding_free_state: 5,
567
              });
568
            }}
569
          />
570
        )}
571
      <Layout isModalOpen={modalOpened} runtimeData={runtimeData.data}>
572
        {/* If free user has reached their free mask limit and 
573
        premium is available in their country, show upsell banner */}
574
        {freeMaskLimitReached &&
61✔
575
          isPeriodicalPremiumAvailableInCountry(runtimeData.data) && (
576
            <UpsellBanner />
577
          )}
578
        {isPhonesAvailableInCountry(runtimeData.data) ? (
46✔
579
          <DashboardSwitcher />
580
        ) : null}
581
        <main className={styles["profile-wrapper"]}>
582
          {stats}
583
          {topBanners}
584
          <section className={styles["main-wrapper"]}>
585
            <Onboarding
586
              aliases={allAliases}
587
              onCreate={() => createAlias({ mask_type: "random" })}
1✔
588
            />
589
            <AliasList
590
              aliases={allAliases}
591
              onCreate={createAlias}
592
              onUpdate={updateAlias}
593
              onDelete={deleteAlias}
594
              profile={profile}
595
              user={user}
596
              runtimeData={runtimeData.data}
597
              setModalOpenedState={setModalOpenedState}
598
            />
599
            <p className={styles["size-information"]}>
600
              {l10n.getString("profile-supports-email-forwarding", {
601
                size: getRuntimeConfig().emailSizeLimitNumber,
602
                unit: getRuntimeConfig().emailSizeLimitUnit,
603
              })}
604
            </p>
605
          </section>
606
          {bottomBanners}
607
        </main>
608
        <CornerNotification
609
          profile={profile}
610
          runtimeData={runtimeData.data}
611
          aliases={allAliases}
612
        />
613
        <Tips profile={profile} runtimeData={runtimeData.data} />
614
      </Layout>
615
    </>
616
  );
617
};
618

619
const StatExplainer = (props: { children: React.ReactNode }) => {
1✔
620
  const l10n = useL10n();
×
621
  const explainerState = useMenuTriggerState({});
×
622
  const overlayRef = useRef<HTMLDivElement>(null);
×
623
  const openButtonRef = useRef<HTMLButtonElement>(null);
×
624
  const closeButtonRef = useRef<HTMLButtonElement>(null);
×
625
  const { triggerProps } = useOverlayTrigger(
×
626
    { type: "dialog" },
627
    explainerState,
628
    openButtonRef,
629
  );
630

631
  const openButtonProps = useButton(triggerProps, openButtonRef).buttonProps;
×
632
  const closeButtonProps = useButton(
×
633
    { onPress: explainerState.close },
634
    closeButtonRef,
635
  ).buttonProps;
636

637
  const positionProps = useOverlayPosition({
×
638
    targetRef: openButtonRef,
639
    overlayRef: overlayRef,
640
    placement: "bottom",
641
    // $spacing-sm is 8px:
642
    offset: 8,
643
    isOpen: explainerState.isOpen,
644
  }).overlayProps;
645

646
  return (
647
    <div
648
      className={`${styles["learn-more-wrapper"]} ${
649
        explainerState.isOpen ? styles["is-open"] : styles["is-closed"]
×
650
      }`}
651
    >
652
      <button
653
        {...openButtonProps}
654
        ref={openButtonRef}
655
        className={styles["open-button"]}
656
      >
657
        {l10n.getString("profile-stat-learn-more")}
658
      </button>
659
      {explainerState.isOpen && (
×
660
        <StatExplainerTooltip
661
          ref={overlayRef}
662
          overlayProps={{
663
            isOpen: explainerState.isOpen,
664
            isDismissable: true,
665
            onClose: explainerState.close,
666
          }}
667
          positionProps={positionProps}
668
        >
669
          <button
670
            ref={closeButtonRef}
671
            {...closeButtonProps}
672
            className={styles["close-button"]}
673
          >
674
            <CloseIcon alt={l10n.getString("profile-stat-learn-more-close")} />
675
          </button>
676
          {props.children}
677
        </StatExplainerTooltip>
678
      )}
679
    </div>
680
  );
681
};
682

683
type StatExplainerTooltipProps = {
684
  children: ReactNode;
685
  overlayProps: Parameters<typeof useOverlay>[0];
686
  positionProps: HTMLAttributes<HTMLDivElement>;
687
};
688
const StatExplainerTooltip = forwardRef<
1✔
689
  HTMLDivElement,
690
  StatExplainerTooltipProps
691
>(function StatExplainerTooltipWithForwardedRef(props, overlayRef) {
692
  const { overlayProps } = useOverlay(
×
693
    props.overlayProps,
694
    overlayRef as RefObject<HTMLDivElement>,
695
  );
696

697
  return (
698
    <FocusScope restoreFocus>
699
      <div
700
        {...overlayProps}
701
        {...props.positionProps}
702
        style={{
703
          ...props.positionProps.style,
704
          // Don't let `useOverlayPosition` handle the horizontal positioning,
705
          // as it will align the tooltip with the `.stat` element, whereas we
706
          // want it to span almost the full width on mobile:
707
          left: undefined,
708
          right: undefined,
709
        }}
710
        ref={overlayRef}
711
        className={styles["learn-more-tooltip"]}
712
      >
713
        {props.children}
714
      </div>
715
    </FocusScope>
716
  );
717
});
718

719
export default Profile;
54✔
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