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

mozilla / fx-private-relay / 869feeee-3f6c-499c-bdc4-7f7d58323555

04 Dec 2023 09:44PM UTC coverage: 73.568% (-0.05%) from 73.614%
869feeee-3f6c-499c-bdc4-7f7d58323555

push

circleci

web-flow
Merge pull request #4176 from mozilla/MPP-3621

MPP-3621: Handle mask generation error for free onboarding

1971 of 2917 branches covered (0.0%)

Branch coverage included in aggregate %.

0 of 10 new or added lines in 2 files covered. (0.0%)

1 existing line in 1 file now uncovered.

6262 of 8274 relevant lines covered (75.68%)

19.69 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
      // TODO: Refactor CustomAddressGenerationModal to remove the setAliasGeneratedState callback, and instead use a try catch block.
UNCOV
188
      setAliasGeneratedState
×
189
        ? setAliasGeneratedState(false)
190
        : toast(l10n.getString("error-mask-create-failed"), { type: "error" });
191

192
      // This is so we can catch the error when calling createAlias asynchronously and apply
193
      // more logic to handle when generating a mask fails.
NEW
194
      return Promise.reject("Mask generation failed");
×
195
    }
196
  };
197

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

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

238
  // We pull UTM parameters from query
239
  const {
240
    utm_campaign = "",
45✔
241
    utm_medium = "",
45✔
242
    utm_source = "",
45✔
243
  } = router.query || {};
45✔
244
  const isFreeUserOnboardingActive = isFlagActive(
45✔
245
    runtimeData.data,
246
    "free_user_onboarding",
247
  );
248

249
  // We validate UTM parameters to ensure they match the expected values for the onboarding campaign
250
  const isValidUtmParameters =
251
    utm_campaign === "relay-onboarding" &&
45!
252
    utm_source === "relay-onboarding" &&
253
    utm_medium === "email";
254

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

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

270
    return (
271
      <>
272
        <AddonData
273
          aliases={allAliases}
274
          profile={profile}
275
          runtimeData={runtimeData.data}
276
          totalBlockedEmails={profile.emails_blocked}
277
          totalForwardedEmails={profile.emails_forwarded}
278
          totalEmailTrackersRemoved={profile.level_one_trackers_blocked}
279
        />
280
        <Layout runtimeData={runtimeData.data}>
281
          {isPhonesAvailableInCountry(runtimeData.data) ? (
×
282
            <DashboardSwitcher />
283
          ) : null}
284
          <FreeOnboarding
285
            profile={profile}
286
            onNextStep={onNextStep}
287
            onPickSubdomain={setCustomSubdomain}
288
            aliases={allAliases}
289
            generateNewMask={createAlias}
290
            hasReachedFreeMaskLimit={freeMaskLimitReached}
291
            user={user}
292
            runtimeData={runtimeData.data}
293
            onUpdate={updateAlias}
294
            hasAtleastOneMask={allAliases.length >= 1}
295
          />
296
        </Layout>
297
      </>
298
    );
299
  }
300

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

330
  const numberFormatter = new Intl.NumberFormat(getLocale(l10n), {
45✔
331
    notation: "compact",
332
    compactDisplay: "short",
333
  });
334

335
  type TooltipProps = {
336
    children: ReactNode;
337
  };
338

339
  const MaxedMasksTooltip = (props: TooltipProps) => {
45✔
340
    const l10n = useL10n();
8✔
341
    const triggerState = useTooltipTriggerState({ delay: 0 });
8✔
342
    const triggerRef = useRef<HTMLSpanElement>(null);
8✔
343
    const tooltipTrigger = useTooltipTrigger({}, triggerState, triggerRef);
8✔
344
    const { tooltipProps } = useTooltip({}, triggerState);
8✔
345

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

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

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

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

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

607
const StatExplainer = (props: { children: React.ReactNode }) => {
1✔
608
  const l10n = useL10n();
×
609
  const explainerState = useMenuTriggerState({});
×
610
  const overlayRef = useRef<HTMLDivElement>(null);
×
611
  const openButtonRef = useRef<HTMLButtonElement>(null);
×
612
  const closeButtonRef = useRef<HTMLButtonElement>(null);
×
613
  const { triggerProps } = useOverlayTrigger(
×
614
    { type: "dialog" },
615
    explainerState,
616
    openButtonRef,
617
  );
618

619
  const openButtonProps = useButton(triggerProps, openButtonRef).buttonProps;
×
620
  const closeButtonProps = useButton(
×
621
    { onPress: explainerState.close },
622
    closeButtonRef,
623
  ).buttonProps;
624

625
  const positionProps = useOverlayPosition({
×
626
    targetRef: openButtonRef,
627
    overlayRef: overlayRef,
628
    placement: "bottom",
629
    // $spacing-sm is 8px:
630
    offset: 8,
631
    isOpen: explainerState.isOpen,
632
  }).overlayProps;
633

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

671
type StatExplainerTooltipProps = {
672
  children: ReactNode;
673
  overlayProps: Parameters<typeof useOverlay>[0];
674
  positionProps: HTMLAttributes<HTMLDivElement>;
675
};
676
const StatExplainerTooltip = forwardRef<
1✔
677
  HTMLDivElement,
678
  StatExplainerTooltipProps
679
>(function StatExplainerTooltipWithForwardedRef(props, overlayRef) {
680
  const { overlayProps } = useOverlay(
×
681
    props.overlayProps,
682
    overlayRef as RefObject<HTMLDivElement>,
683
  );
684

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

707
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