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

mozilla / fx-private-relay / def59674-18f9-4de3-b49d-139136c466b5

08 Dec 2023 09:55PM CUT coverage: 73.596% (+0.05%) from 73.551%
def59674-18f9-4de3-b49d-139136c466b5

Pull #4177

circleci

jwhitlock
Move commands below executors
Pull Request #4177: MPP-3390: CircleCI config refactor

1970 of 2915 branches covered (0.0%)

Branch coverage included in aggregate %.

6261 of 8269 relevant lines covered (75.72%)

19.7 hits per line

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

71.7
/frontend/src/pages/accounts/profile.page.tsx
1
import type { NextPage } from "next";
2
import Image from "next/image";
1✔
3
import {
4
  forwardRef,
5
  HTMLAttributes,
6
  ReactNode,
7
  RefObject,
8
  useRef,
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 { event as gaEvent } from "react-ga";
1✔
21
import { useMenuTriggerState, useTooltipTriggerState } from "react-stately";
1✔
22
import { toast } from "react-toastify";
1✔
23
import styles from "./profile.module.scss";
1✔
24
import UpsellBannerUs from "./images/upsell-banner-us.svg";
1✔
25
import UpsellBannerNonUs from "./images/upsell-banner-nonus.svg";
1✔
26
import { CheckBadgeIcon, LockIcon, PencilIcon } from "../../components/Icons";
1✔
27
import { Layout } from "../../components/layout/Layout";
1✔
28
import { useProfiles } from "../../hooks/api/profile";
1✔
29
import {
30
  AliasData,
31
  getAllAliases,
32
  getFullAddress,
33
  useAliases,
34
} from "../../hooks/api/aliases";
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 { isFlagActive } from "../../functions/waffle";
1✔
54
import { DashboardSwitcher } from "../../components/layout/navigation/DashboardSwitcher";
1✔
55
import { usePurchaseTracker } from "../../hooks/purchaseTracker";
1✔
56
import { PremiumPromoBanners } from "../../components/dashboard/PremiumPromoBanners";
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

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

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

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

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

125
  const allAliases = getAllAliases(
52✔
126
    aliasData.randomAliasData.data,
127
    aliasData.customAliasData.data,
128
  );
129

130
  // premium user onboarding experience
131
  if (
52✔
132
    profile.has_premium &&
77✔
133
    profile.onboarding_state < getRuntimeConfig().maxOnboardingAvailable
134
  ) {
135
    const onNextStep = (step: number) => {
7✔
136
      profileData.update(profile.id, {
2✔
137
        onboarding_state: step,
138
      });
139
    };
140

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

165
  const freeMaskLimit = getRuntimeConfig().maxFreeAliases;
45✔
166
  const freeMaskLimitReached =
167
    allAliases.length >= freeMaskLimit && !profile.has_premium;
45✔
168

169
  const createAlias = async (
45✔
170
    options:
171
      | { mask_type: "random" }
172
      | { mask_type: "custom"; address: string; blockPromotionals: boolean },
173
    setAliasGeneratedState?: (flag: boolean) => void,
174
  ) => {
175
    try {
1✔
176
      const response = await aliasData.create(options);
1✔
177
      if (!response.ok) {
1!
178
        throw new Error(
×
179
          "Immediately caught to land in the same code path as failed requests.",
180
        );
181
      }
182
      if (setAliasGeneratedState) {
1!
183
        setAliasGeneratedState(true);
×
184
      }
185
      addonData.sendEvent("aliasListUpdate");
1✔
186
    } catch (error) {
187
      setAliasGeneratedState
×
188
        ? setAliasGeneratedState(false)
189
        : toast(l10n.getString("error-mask-create-failed"), { type: "error" });
190
    }
191
  };
192

193
  const updateAlias = async (
45✔
194
    alias: AliasData,
195
    updatedFields: Partial<AliasData>,
196
  ) => {
197
    try {
1✔
198
      const response = await aliasData.update(alias, updatedFields);
1✔
199
      if (!response.ok) {
1!
200
        throw new Error(
×
201
          "Immediately caught to land in the same code path as failed requests.",
202
        );
203
      }
204
    } catch (error) {
205
      toast(
1✔
206
        l10n.getString("error-mask-update-failed", {
207
          alias: getFullAddress(alias),
208
        }),
209
        { type: "error" },
210
      );
211
    }
212
  };
213

214
  const deleteAlias = async (alias: AliasData) => {
45✔
215
    try {
1✔
216
      const response = await aliasData.delete(alias);
1✔
217
      if (!response.ok) {
1!
218
        throw new Error(
×
219
          "Immediately caught to land in the same code path as failed requests.",
220
        );
221
      }
222
      addonData.sendEvent("aliasListUpdate");
1✔
223
    } catch (error: unknown) {
224
      toast(
×
225
        l10n.getString("error-mask-delete-failed", {
226
          alias: getFullAddress(alias),
227
        }),
228
        { type: "error" },
229
      );
230
    }
231
  };
232

233
  // We pull UTM parameters from query
234
  const {
235
    utm_campaign = "",
45✔
236
    utm_medium = "",
45✔
237
    utm_source = "",
45✔
238
  } = router.query || {};
45✔
239
  const isFreeUserOnboardingActive = isFlagActive(
45✔
240
    runtimeData.data,
241
    "free_user_onboarding",
242
  );
243

244
  // We validate UTM parameters to ensure they match the expected values for the onboarding campaign
245
  const isValidUtmParameters =
246
    utm_campaign === "relay-onboarding" &&
45!
247
    utm_source === "relay-onboarding" &&
248
    utm_medium === "email";
249

250
  // Determine if the user is part of the target audience for onboarding
251
  // This checks if the user does not have a premium account and has not completed all onboarding steps
252
  const isTargetAudience =
253
    !profile.has_premium &&
45✔
254
    profile.onboarding_free_state <
255
      getRuntimeConfig().maxOnboardingFreeAvailable;
256

257
  // Conditions: onboarding is active, UTM parameters are valid, and the user is part of the target audience
258
  if (isFreeUserOnboardingActive && isValidUtmParameters && isTargetAudience) {
45!
259
    const onNextStep = (step: number) => {
×
260
      profileData.update(profile.id, {
×
261
        onboarding_free_state: step,
262
      });
263
    };
264

265
    return (
266
      <>
267
        <AddonData
268
          aliases={allAliases}
269
          profile={profile}
270
          runtimeData={runtimeData.data}
271
          totalBlockedEmails={profile.emails_blocked}
272
          totalForwardedEmails={profile.emails_forwarded}
273
          totalEmailTrackersRemoved={profile.level_one_trackers_blocked}
274
        />
275
        <Layout runtimeData={runtimeData.data}>
276
          {isPhonesAvailableInCountry(runtimeData.data) ? (
×
277
            <DashboardSwitcher />
278
          ) : null}
279
          <FreeOnboarding
280
            profile={profile}
281
            onNextStep={onNextStep}
282
            onPickSubdomain={setCustomSubdomain}
283
            aliases={allAliases}
284
            generateNewMask={() => createAlias({ mask_type: "random" })}
×
285
            hasReachedFreeMaskLimit={freeMaskLimitReached}
286
            user={user}
287
            runtimeData={runtimeData.data}
288
            onUpdate={updateAlias}
289
          />
290
        </Layout>
291
      </>
292
    );
293
  }
294

295
  const subdomainMessage =
296
    typeof profile.subdomain === "string" ? (
297
      <>
2✔
298
        <span>{l10n.getString("profile-label-custom-domain")}</span>
299
        <span className={styles["profile-registered-domain-value"]}>
300
          @{profile.subdomain}.{getRuntimeConfig().mozmailDomain}
301
        </span>
302
      </>
303
    ) : profile.has_premium ? (
304
      <a className={styles["open-button"]} href="#mpp-choose-subdomain">
16✔
305
        {l10n.getString("profile-label-set-your-custom-domain-free-user")}
306
      </a>
307
    ) : (
308
      <Link
309
        className={styles["open-button"]}
310
        href={"/premium#pricing"}
311
        ref={setCustomDomainLinkRef}
312
        onClick={() => {
313
          gaEvent({
×
314
            category: "Purchase Button",
315
            action: "Engage",
316
            label: "profile-set-custom-domain",
317
          });
318
        }}
319
      >
320
        {l10n.getString("profile-label-set-your-custom-domain-free-user")}
321
      </Link>
322
    );
323

324
  const numberFormatter = new Intl.NumberFormat(getLocale(l10n), {
45✔
325
    notation: "compact",
326
    compactDisplay: "short",
327
  });
328

329
  type TooltipProps = {
330
    children: ReactNode;
331
  };
332

333
  const MaxedMasksTooltip = (props: TooltipProps) => {
45✔
334
    const l10n = useL10n();
8✔
335
    const triggerState = useTooltipTriggerState({ delay: 0 });
8✔
336
    const triggerRef = useRef<HTMLSpanElement>(null);
8✔
337
    const tooltipTrigger = useTooltipTrigger({}, triggerState, triggerRef);
8✔
338
    const { tooltipProps } = useTooltip({}, triggerState);
8✔
339

340
    return (
341
      <div className={styles["stat-wrapper"]}>
342
        <span
343
          ref={triggerRef}
344
          {...tooltipTrigger.triggerProps}
345
          className={`${styles.stat} ${styles["forwarded-stat"]}`}
346
        >
347
          {props.children}
348
        </span>
349
        {triggerState.isOpen && (
8✔
350
          <div
351
            {...mergeProps(tooltipTrigger.tooltipProps, tooltipProps)}
352
            className={styles.tooltip}
353
          >
354
            <p>
355
              {l10n.getString("profile-maxed-aliases-tooltip", {
356
                limit: freeMaskLimit,
357
              })}
358
            </p>
359
          </div>
360
        )}
361
      </div>
362
    );
363
  };
364

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

464
  const banners = (
465
    <section className={styles["banners-wrapper"]}>
466
      {!profile.has_premium &&
142!
467
      isPeriodicalPremiumAvailableInCountry(runtimeData.data) &&
468
      isFlagActive(runtimeData.data, "premium_promo_banners") ? (
469
        <PremiumPromoBanners profile={profile} />
470
      ) : null}
471
      <ProfileBanners
472
        profile={profile}
473
        user={user}
474
        onCreateSubdomain={setCustomSubdomain}
475
        runtimeData={runtimeData.data}
476
        aliases={allAliases}
477
      />
478
    </section>
479
  );
480
  const topBanners = allAliases.length > 0 ? banners : null;
45✔
481
  const bottomBanners = allAliases.length === 0 ? banners : null;
45✔
482

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

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

601
const StatExplainer = (props: { children: React.ReactNode }) => {
1✔
602
  const l10n = useL10n();
×
603
  const explainerState = useMenuTriggerState({});
×
604
  const overlayRef = useRef<HTMLDivElement>(null);
×
605
  const openButtonRef = useRef<HTMLButtonElement>(null);
×
606
  const closeButtonRef = useRef<HTMLButtonElement>(null);
×
607
  const { triggerProps } = useOverlayTrigger(
×
608
    { type: "dialog" },
609
    explainerState,
610
    openButtonRef,
611
  );
612

613
  const openButtonProps = useButton(triggerProps, openButtonRef).buttonProps;
×
614
  const closeButtonProps = useButton(
×
615
    { onPress: explainerState.close },
616
    closeButtonRef,
617
  ).buttonProps;
618

619
  const positionProps = useOverlayPosition({
×
620
    targetRef: openButtonRef,
621
    overlayRef: overlayRef,
622
    placement: "bottom",
623
    // $spacing-sm is 8px:
624
    offset: 8,
625
    isOpen: explainerState.isOpen,
626
  }).overlayProps;
627

628
  return (
629
    <div
630
      className={`${styles["learn-more-wrapper"]} ${
631
        explainerState.isOpen ? styles["is-open"] : styles["is-closed"]
×
632
      }`}
633
    >
634
      <button
635
        {...openButtonProps}
636
        ref={openButtonRef}
637
        className={styles["open-button"]}
638
      >
639
        {l10n.getString("profile-stat-learn-more")}
640
      </button>
641
      {explainerState.isOpen && (
×
642
        <StatExplainerTooltip
643
          ref={overlayRef}
644
          overlayProps={{
645
            isOpen: explainerState.isOpen,
646
            isDismissable: true,
647
            onClose: explainerState.close,
648
          }}
649
          positionProps={positionProps}
650
        >
651
          <button
652
            ref={closeButtonRef}
653
            {...closeButtonProps}
654
            className={styles["close-button"]}
655
          >
656
            <CloseIcon alt={l10n.getString("profile-stat-learn-more-close")} />
657
          </button>
658
          {props.children}
659
        </StatExplainerTooltip>
660
      )}
661
    </div>
662
  );
663
};
664

665
type StatExplainerTooltipProps = {
666
  children: ReactNode;
667
  overlayProps: Parameters<typeof useOverlay>[0];
668
  positionProps: HTMLAttributes<HTMLDivElement>;
669
};
670
const StatExplainerTooltip = forwardRef<
1✔
671
  HTMLDivElement,
672
  StatExplainerTooltipProps
673
>(function StatExplainerTooltipWithForwardedRef(props, overlayRef) {
674
  const { overlayProps } = useOverlay(
×
675
    props.overlayProps,
676
    overlayRef as RefObject<HTMLDivElement>,
677
  );
678

679
  return (
680
    <FocusScope restoreFocus>
681
      <div
682
        {...overlayProps}
683
        {...props.positionProps}
684
        style={{
685
          ...props.positionProps.style,
686
          // Don't let `useOverlayPosition` handle the horizontal positioning,
687
          // as it will align the tooltip with the `.stat` element, whereas we
688
          // want it to span almost the full width on mobile:
689
          left: undefined,
690
          right: undefined,
691
        }}
692
        ref={overlayRef}
693
        className={styles["learn-more-tooltip"]}
694
      >
695
        {props.children}
696
      </div>
697
    </FocusScope>
698
  );
699
});
700

701
export default Profile;
52✔
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