Skip to content

feat(invites): viral share hub with hero card + one-tap social share#860

Closed
jwalin-shah wants to merge 1 commit into
tinyhumansai:mainfrom
jwalin-shah:claude/optimize-viral-growth-keauq
Closed

feat(invites): viral share hub with hero card + one-tap social share#860
jwalin-shah wants to merge 1 commit into
tinyhumansai:mainfrom
jwalin-shah:claude/optimize-viral-growth-keauq

Conversation

@jwalin-shah
Copy link
Copy Markdown
Contributor

@jwalin-shah jwalin-shah commented Apr 23, 2026

Opening as Draft — this is a product direction call, not a mechanical change.

What it does

  • Hero share card on the invites surface
  • One-tap share buttons for major social platforms
  • Invite-nudge surface to re-engage users with unused invites

+974 / −198 lines across 11 files.

Open questions

  • Is viral / social share the invite direction you want?
  • Does the hero-card design fit the current calm-UI guideline?

Close this if the direction doesn't fit — worst case we learned what shape an invite revamp would take.

Summary by CodeRabbit

  • New Features

    • Added social sharing buttons for inviting friends across multiple platforms (Twitter, Telegram, WhatsApp, LinkedIn, Facebook, Reddit, email, SMS).
    • Introduced invite progress tracking with visual indicators and conversion metrics.
    • Added new invite card UI with copy-to-clipboard functionality for invite links and codes.
    • Launched invite call-to-action on the home page.
    • Enhanced welcome and invites pages with social proof elements ("Trusted by builders worldwide").
  • Tests

    • Added comprehensive unit tests for share utilities.

…ite nudge

Turn invite/referral flows into proper viral loops so users actually share:

- Hero ViralInviteCard with gradient mesh — screenshottable, personalized,
  tap-to-copy code + short URL.
- SocialShareRow with platform-native intents (X, Telegram, WhatsApp,
  LinkedIn, Reddit, Email), native share sheet fallback, and copy button.
- InviteProgressBar gamifies the "share your 5 codes" goal.
- Invites page redesigned around the hero + one-tap share; Referral Rewards
  section uses the same hero + share row (unified UX).
- Home gains a gradient-wrapped "Give OpenHuman to a friend" nudge that
  links into the full share flow.
- Welcome swaps generic copy for a tighter value prop + social-proof row.
- New share utils: buildInviteUrl, buildShareUrl, shareOn, tryNativeShare,
  copyToClipboard (with execCommand fallback for locked-down webviews).
- New VITE_SHARE_BASE_URL config (default https://openhuman.ai) for the
  public invite links that land friends on the web entry point.
- Unit tests cover URL builders, every platform intent, clipboard fallback
  path, and native share AbortError handling.

https://claude.ai/code/session_0158dPfhqG9hQ1vkb4AoVkb3
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

The PR introduces a modularized sharing and referral invite system, replacing bespoke component logic with reusable utilities (buildInviteUrl, shareOn, copyToClipboard) and three new UI components (ViralInviteCard, SocialShareRow, InviteProgressBar). Affected pages restructure their invite flows to use the new components, and config gains a SHARE_BASE_URL setting for invite link generation.

Changes

Cohort / File(s) Summary
Sharing Utilities
app/src/utils/share.ts, app/src/utils/config.ts
Adds SHARE_BASE_URL config and new share.ts module with functions for building invite URLs, generating platform-specific share links (Twitter, Telegram, WhatsApp, LinkedIn, Facebook, Reddit, email, SMS), clipboard operations with fallbacks, and native OS/browser share sheet integration.
Shared UI Components
app/src/components/share/ViralInviteCard.tsx, app/src/components/share/SocialShareRow.tsx, app/src/components/share/InviteProgressBar.tsx
Introduces three new components: ViralInviteCard displays invite code/URL hero card with copy feedback; SocialShareRow renders platform buttons plus copy/native share with state tracking; InviteProgressBar shows referral conversion progress with dynamic messaging.
Component Refactoring
app/src/components/rewards/ReferralRewardsSection.tsx, app/src/pages/Invites.tsx
ReferralRewardsSection replaces internal copy/share logic with ViralInviteCard and SocialShareRow components. Invites restructures to use new components, builds full invite URLs instead of raw codes, updates redeemed display labels, and adds invite progress tracking.
Page Updates
app/src/pages/Home.tsx, app/src/pages/Welcome.tsx
Home adds full-width invite CTA button linking to /invites. Welcome updates hero copy, tightens styling, and adds social-proof avatar section.
Testing & Setup
app/src/test/setup.ts, app/src/utils/__tests__/share.test.ts
Test setup mocks SHARE_BASE_URL config. New comprehensive test file validates buildInviteUrl, platform-specific buildShareUrl generation, clipboard operations with fallbacks, and native share handling.

Sequence Diagram

sequenceDiagram
    actor User
    participant ViralInviteCard as ViralInviteCard<br/>(UI)
    participant SocialShareRow as SocialShareRow<br/>(UI)
    participant Utilities as Share Utilities<br/>(shareOn, copyToClipboard,<br/>tryNativeShare)
    participant Browser/OS as Browser/OS APIs<br/>(clipboard, navigator.share)

    User->>ViralInviteCard: Click "Copy code"
    ViralInviteCard->>Utilities: copyToClipboard(code)
    Utilities->>Browser/OS: navigator.clipboard.writeText()
    Browser/OS-->>Utilities: success/failure
    Utilities-->>ViralInviteCard: boolean result
    ViralInviteCard->>ViralInviteCard: Update button to "Copied!"
    ViralInviteCard->>User: Show feedback

    User->>SocialShareRow: Click platform button
    SocialShareRow->>Utilities: shareOn(platform, text, url)
    Utilities->>Utilities: buildShareUrl() → platform-specific URL
    Utilities->>Browser/OS: window.open(shareUrl)
    Browser/OS-->>User: Open platform in browser

    User->>SocialShareRow: Click "More" (native share)
    SocialShareRow->>Utilities: tryNativeShare({title, text, url})
    Utilities->>Browser/OS: navigator.share(payload)
    alt Native share available & user completes
        Browser/OS-->>Utilities: resolved
        Utilities-->>SocialShareRow: true
    else User cancels (AbortError) or unavailable
        Browser/OS-->>Utilities: AbortError / undefined
        Utilities->>Utilities: Fallback: copyToClipboard(url)
        Utilities-->>SocialShareRow: true/false
    end
    SocialShareRow->>SocialShareRow: Update button state
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

Suggested Reviewers

  • senamakel

🐰 Hops excitedly with carrot in paw

Share buttons sprouted, invites take flight,
Utilities bundled, components bright!
URL-builders and native shares too,
Progress bars showing what friends will do. 🥕✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly reflects the main changes: introducing a viral share hub with a hero card component and social share buttons for one-tap sharing on the invites surface.
Docstring Coverage ✅ Passed Docstring coverage is 81.82% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch claude/optimize-viral-growth-keauq

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@jwalin-shah jwalin-shah marked this pull request as ready for review April 25, 2026 08:03
@jwalin-shah jwalin-shah requested a review from a team April 25, 2026 08:03
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (9)
app/src/pages/Home.tsx (1)

275-307: Duplicate invite CTA on Home.

This new gradient nudge navigates to /invites, and the existing "Invite a friend" row inside the "Next steps" list (lines 356–377) already does the same thing — both are visible on the first paint. Consider removing one to avoid two CTAs to the same destination on a single viewport. Given the PR description calls out calm-UI fit as an open question, dropping the bigger gradient card (or replacing the Next-steps row with it) would keep the surface calmer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/pages/Home.tsx` around lines 275 - 307, There are two visible CTAs
that both navigate to '/invites': the new gradient invite card (the button with
onClick={() => navigate('/invites')} and the "Give OpenHuman to a friend"
content) and the existing "Invite a friend" row inside the Next steps list;
remove or consolidate one to avoid duplicate CTAs on first paint. Either delete
the whole gradient button block (the <button ...> wrapper with the "Give
OpenHuman to a friend" content) or replace the Next steps "Invite a friend" list
item with this new gradient card so only one invite CTA remains; ensure
navigation still uses navigate('/invites') and style/aria-label are preserved if
you keep the gradient version.
app/src/components/share/ViralInviteCard.tsx (2)

36-42: Cancel the reset timer on unmount / re-trigger.

If the user navigates away within 1.8s of copying, the timer still fires and calls setCopiedTarget on an unmounted component. React 19 swallows the warning but the leaked timer remains. Also, rapid double-clicks stack timeouts that race each other. Tracking the handle in a ref and clearing on unmount fixes both.

♻️ Suggested change
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@
-  const [copiedTarget, setCopiedTarget] = useState<'code' | 'url' | null>(null);
+  const [copiedTarget, setCopiedTarget] = useState<'code' | 'url' | null>(null);
+  const resetTimerRef = useRef<number | null>(null);
+
+  useEffect(
+    () => () => {
+      if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current);
+    },
+    []
+  );
@@
-  const copy = useCallback(async (target: 'code' | 'url', text: string) => {
-    const ok = await copyToClipboard(text);
-    if (ok) {
-      setCopiedTarget(target);
-      setTimeout(() => setCopiedTarget(null), 1800);
-    }
-  }, []);
+  const copy = useCallback(async (target: 'code' | 'url', text: string) => {
+    const ok = await copyToClipboard(text);
+    if (!ok) return;
+    setCopiedTarget(target);
+    if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current);
+    resetTimerRef.current = window.setTimeout(() => {
+      resetTimerRef.current = null;
+      setCopiedTarget(null);
+    }, 1800);
+  }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/share/ViralInviteCard.tsx` around lines 36 - 42, The copy
callback sets a 1.8s timeout but never clears it, causing timers to fire after
unmount or stack on rapid re-triggers; update the copy function (useCallback
named copy in ViralInviteCard) to store the timeout id in a ref (e.g.,
copyTimeoutRef), clear any existing timeout before creating a new one, and clear
the ref on unmount (useEffect cleanup) so setCopiedTarget is never called after
the component unmounts and multiple clicks don't stack timers.

87-103: Disable code-copy button when code is empty.

When code is empty, the UI shows but the button still fires copyToClipboard("") and reports success on most browsers — confusing for the user. Cheap guard:

-          <button
-            type="button"
-            onClick={() => void copy('code', code)}
+          <button
+            type="button"
+            onClick={() => void copy('code', code)}
+            disabled={!code}
             className="group flex items-center justify-between rounded-2xl border border-white/15 bg-white/10 px-4 py-3 text-left backdrop-blur-md transition-colors hover:bg-white/15"
             aria-label="Copy invite code">

(Same idea applies to the link button if url is ever empty.)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/share/ViralInviteCard.tsx` around lines 87 - 103, The copy
button currently calls copy('code', code) even when code is empty; update the
ViralInviteCard button so it is disabled when code is falsy: guard the onClick
to no-op (or remove) when !code, add disabled and aria-disabled attributes, and
adjust the className to reflect disabled styling; ensure the copiedTarget logic
only sets "Copied!" when a real code was copied. Apply the same pattern to the
link button that calls copy('link', url) if url can be empty.
app/src/pages/Welcome.tsx (1)

65-76: Decorative "social proof" with no actual proof.

The four colored circles aren't avatars and "Trusted by builders worldwide" isn't backed by anything visible (a count, real avatars, logos). On the pre-auth landing page, this risks reading as a manufactured trust signal. Consider either (a) replacing with a concrete signal you can stand behind (real user count, named partners, GitHub stars, press logos) or (b) dropping it to keep the page calm — the PR description itself flags calm-UI fit as an open question.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/pages/Welcome.tsx` around lines 65 - 76, The decorative "social
proof" block in Welcome.tsx (the div with className "flex items-center
justify-center gap-2 mb-6" that contains the colored avatar spans and the
"Trusted by builders worldwide" text) should be replaced or removed: either swap
the four colored span placeholders with a concrete, verifiable signal (e.g., a
real users count element, partner/logo images, or GitHub stars component and
update the accompanying text accordingly) or remove the entire div to avoid a
manufactured trust signal; update any related accessibility text or aria-labels
for the new assets and ensure styling/layout (the inner "flex -space-x-1.5"
group and the text span with className "text-[11px] font-medium text-stone-500")
remain consistent with the page’s calm-UI design.
app/src/components/share/InviteProgressBar.tsx (1)

36-46: Optional: associate the visible label with the progressbar for screen readers.

The role="progressbar" element has no accessible name — "Your invite streak" is a sibling, not linked. Adding aria-labelledby (or aria-label) makes the bar meaningful in a11y tools.

♻️ Suggested change
-      <div className="flex items-center justify-between text-[11px] font-medium uppercase tracking-wider text-stone-400">
-        <span>Your invite streak</span>
+      <div className="flex items-center justify-between text-[11px] font-medium uppercase tracking-wider text-stone-400">
+        <span id="invite-progress-label">Your invite streak</span>
         <span>
           {clamped}/{cap}
         </span>
       </div>
       <div
         className="h-2.5 w-full rounded-full bg-stone-100"
         role="progressbar"
+        aria-labelledby="invite-progress-label"
         aria-valuemin={0}
         aria-valuemax={cap}
         aria-valuenow={clamped}>

Note: if multiple InviteProgressBar instances ever render on the same page, swap the static id for useId() to keep ids unique.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/share/InviteProgressBar.tsx` around lines 36 - 46, The
progressbar DIV that has role="progressbar" in InviteProgressBar has no
accessible name; link it to the visible label by adding aria-labelledby pointing
at that label's id (or add aria-label) and ensure the label element has a unique
id; generate a unique id using React's useId() inside InviteProgressBar and
apply it to the label (e.g., the "Your invite streak" text) and to the
progressbar's aria-labelledby so screen readers associate the two (if you prefer
a single static label fallback, use aria-label but prefer aria-labelledby with
useId for multiple instances).
app/src/utils/share.ts (2)

105-125: AbortError → true conflates "shared" with "cancelled" for callers.

Returning true on user cancellation is convenient for the "should I show a copy fallback?" question but loses information for analytics and UX. SocialShareRow.handleNativeShare (Line 178-182) reports onShare('native') for both genuine shares and cancellations, so any share-funnel metric built on this will be inflated. Consider returning a discriminated result instead:

type NativeShareResult = 'shared' | 'cancelled' | 'unsupported' | 'failed';

…and let callers decide whether cancellation should suppress the copy fallback (yes) and whether it should fire analytics (no). This is the same concern raised on SocialShareRow.tsx; flagging it here as the root cause.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/utils/share.ts` around lines 105 - 125, Change tryNativeShare to
return a discriminated result type instead of boolean: define type
NativeShareResult = 'shared' | 'cancelled' | 'unsupported' | 'failed' and change
the function signature to Promise<NativeShareResult>; if navigator or
navigator.share is unavailable return 'unsupported', on successful await return
'shared', if caught error has name 'AbortError' return 'cancelled', otherwise
log the error and return 'failed'; update callers like
SocialShareRow.handleNativeShare to branch on these string results so
cancellation can be treated differently from a real share or a failure.

31-33: Hardcoded English share copy.

defaultInviteMessage will surface this exact string into every platform intent regardless of the user's locale. Once i18n lands (or if it's already in place via another util), this should route through the translation layer. Not a blocker for the prototype.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/utils/share.ts` around lines 31 - 33, The share message is hardcoded
in defaultInviteMessage — route it through the app's i18n/translation layer
instead. Replace the plain template in defaultInviteMessage(code, url) with a
call to the translation utility (e.g., t('invite.message', { code, url }) or
i18n.translate('invite.message', { code, url })) so the string is localized and
uses interpolation keys; update translation resource files to include the
corresponding message key for each locale.
app/src/utils/__tests__/share.test.ts (2)

86-109: Missing happy-path coverage for tryNativeShare.

The current tests only cover (a) navigator.share missing and (b) AbortError. There's no assertion that tryNativeShare returns true and forwards the payload to navigator.share on a normal resolve, nor that other rejections (e.g. NotAllowedError) return false. These are the cases the SocialShareRow fallback chain actually depends on.

🧪 Suggested additional cases
+  it('forwards payload and returns true on success', async () => {
+    const share = vi.fn().mockResolvedValue(undefined);
+    Object.defineProperty(navigator, 'share', { value: share, configurable: true });
+    const payload = { title: 't', text: 'm', url: 'x' };
+    await expect(tryNativeShare(payload)).resolves.toBe(true);
+    expect(share).toHaveBeenCalledWith(payload);
+  });
+
+  it('returns false on non-Abort rejection', async () => {
+    const share = vi.fn().mockRejectedValue(new Error('boom'));
+    Object.defineProperty(navigator, 'share', { value: share, configurable: true });
+    await expect(tryNativeShare({ url: 'x' })).resolves.toBe(false);
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/utils/__tests__/share.test.ts` around lines 86 - 109, Add two more
tests for tryNativeShare: one "returns true and forwards payload on successful
share" that stubs navigator.share with a vi.fn().mockResolvedValue(undefined),
calls tryNativeShare with a payload (e.g., { url: 'x', text: 'y' }), asserts it
resolves to true and that the mock was called with the exact payload; and one
"returns false for non-AbortError rejections" that stubs navigator.share with
vi.fn().mockRejectedValue(err) where err.name = 'NotAllowedError' (or another
non-'AbortError'), asserts tryNativeShare resolves to false and that the mock
was called. Use Object.defineProperty to set navigator.share and restore the
original as in existing tests.

26-55: Coverage gap on buildShareUrl and defaultInviteMessage.

Half of the SharePlatform union — linkedin, facebook, reddit, sms — is untested in buildShareUrl, and defaultInviteMessage (also exported from share.ts) has no tests at all. Given these strings are user-visible and feed share intents, regressions (e.g. accidentally swapping text/url query params) won't be caught by the current suite. Adding a single parametrized it.each would cover all eight platforms cheaply.

🧪 Suggested addition
+  it.each([
+    ['linkedin', 'https://www.linkedin.com/sharing/share-offsite/?url='],
+    ['facebook', 'https://www.facebook.com/sharer/sharer.php?u='],
+    ['reddit', 'https://www.reddit.com/submit?'],
+    ['sms', 'sms:?&body='],
+  ] as const)('builds %s share url', (platform, prefix) => {
+    const result = buildShareUrl(platform, text, url);
+    expect(result.startsWith(prefix)).toBe(true);
+    expect(result).toContain(encodeURIComponent(url));
+  });
+
+  it('defaultInviteMessage embeds code and url', () => {
+    const msg = defaultInviteMessage('ABC123', 'https://openhuman.ai/i/ABC123');
+    expect(msg).toContain('ABC123');
+    expect(msg).toContain('https://openhuman.ai/i/ABC123');
+  });

As per coding guidelines: "Ship unit tests and coverage for behavior you are adding or changing before building additional features on top".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/utils/__tests__/share.test.ts` around lines 26 - 55, Tests for
buildShareUrl and defaultInviteMessage are incomplete: add parameterized tests
covering the remaining SharePlatform values ('linkedin', 'facebook', 'reddit',
'sms') and assert their expected URL patterns/encoded params, and add tests for
defaultInviteMessage to ensure it returns the expected invite string; update
app/src/utils/__tests__/share.test.ts to use it.each with rows for all eight
platforms and expected substrings (referencing buildShareUrl and SharePlatform)
and add a separate test asserting defaultInviteMessage's exact value or required
substrings.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/components/rewards/ReferralRewardsSection.tsx`:
- Around line 92-99: The share URL currently falls back to the raw referralCode
in the useMemo that computes shareUrl (using stats?.referralLink?.trim() ||
stats?.referralCode?.trim()), which breaks downstream ViralInviteCard.url and
SocialShareRow.url; change the fallback to construct a proper URL via
buildInviteUrl(...) instead. Locate the useMemo that defines shareUrl and
replace the fallback to stats?.referralCode?.trim() with
buildInviteUrl(stats?.referralCode?.trim() || ''), and update any other
identical fallback useMemo instances (the similar blocks around the other
occurrences referenced) so defaultInviteMessage and components receive a full
URL rather than a bare code. Ensure you import or reference buildInviteUrl where
needed and preserve trimming/empty-string guards.

In `@app/src/components/share/SocialShareRow.tsx`:
- Around line 242-247: The copy button in SocialShareRow contains a redundant
ternary for sizing; replace the duplicated conditional in the className on the
button (the expression that currently returns 'h-9 px-3 text-xs font-medium' for
both dense branches) with a single class string to collapse the ternary, and
while editing confirm whether the copy button should mimic the other platform
buttons' dense behavior (i.e., become icon-only with classes like 'h-9 w-9') and
implement that change on the same button if intended; locate the button by
referencing SocialShareRow, the onClick handler handleCopy, and the copied/dense
props to make the update.
- Around line 176-191: The native-share flow conflates user-cancel with success
and fires onShare('native') even on AbortError; update tryNativeShare to return
a tri-state (e.g. 'shared' | 'cancelled' | 'unsupported') and change
handleNativeShare to only call onShare('native') when the result === 'shared'
while falling back to handleCopy for 'unsupported' and doing nothing for
'cancelled'. Also wrap the await shareOn(...) call inside handlePlatform in a
try/catch so that if shareOn (or openUrl) rejects you still call or safely skip
onShare(platform) and avoid unhandled promise rejections; reference functions:
tryNativeShare, handleNativeShare, handleCopy, shareOn, handlePlatform, and
onShare.
- Around line 1-27: The PlatformMeta interface uses the type-only
React.ReactNode; change this to a type import to follow guidelines: add "import
type { ReactNode } from 'react'" at the top and update the PlatformMeta.icon
declaration to use ReactNode (remove the React. prefix). Locate the PlatformMeta
interface in SocialShareRow.tsx and replace the type usage accordingly so only a
type-only import is used.

In `@app/src/pages/Invites.tsx`:
- Around line 143-178: availableCode currently falls back to codes[0], which can
be fully redeemed and causes the hero (ViralInviteCard / SocialShareRow) to
render incorrectly; change availableCode to return undefined when no code has
currentUses < maxUses (i.e. remove the fallback to codes[0]) so heroCode becomes
undefined in that case, and ensure heroUrl and heroMessage derive from heroCode
so the hero conditional (heroCode ? …) naturally prevents rendering the invite
card and share row when all codes are maxed out.

In `@app/src/utils/share.ts`:
- Around line 24-28: The buildInviteUrl function silently returns SHARE_BASE_URL
when code is empty/whitespace; change buildInviteUrl (exported function
buildInviteUrl) to return an empty string (or throw) when trimmed is falsy
instead of returning SHARE_BASE_URL, so callers can detect "no code" (e.g.,
heroCode = availableCode?.code ?? '') and avoid rendering the share affordance;
update any call sites that currently assume a URL (particularly the component
that renders the share row) to gate rendering on a non-empty return from
buildInviteUrl.
- Around line 18-28: The JSDoc for buildInviteUrl is inaccurate: it claims the
invite is a ?invite=CODE query and mentions openhuman:// deep links, but the
function (buildInviteUrl) actually returns a path-style URL using SHARE_BASE_URL
+ '/i/' + encodeURIComponent(trimmed). Update the JSDoc to describe the actual
behavior (trims the code, URL-encodes it, and returns SHARE_BASE_URL/i/CODE) and
remove the misleading deep-link claim (or, if you prefer to implement
deep-linking, change buildInviteUrl to also return the deep link or provide a
separate function). Ensure the doc references buildInviteUrl and SHARE_BASE_URL
and accurately documents trimming and encoding.
- Line 51: The sms share URI currently built in the sms property (sms: (text,
url) => `sms:?&body=...`) uses the `?&` separator which breaks body parsing on
iOS; update the sms builder in the sms function to use
`sms:?body=${encodeURIComponent(`${text} ${url}`)}` (remove the extra `&`) for
cross-platform compatibility, or alternatively implement simple UA detection
inside the same sms function to return `sms:;body=...` for iOS user agents and
`sms:?body=...` for others if you need to handle edge cases.

---

Nitpick comments:
In `@app/src/components/share/InviteProgressBar.tsx`:
- Around line 36-46: The progressbar DIV that has role="progressbar" in
InviteProgressBar has no accessible name; link it to the visible label by adding
aria-labelledby pointing at that label's id (or add aria-label) and ensure the
label element has a unique id; generate a unique id using React's useId() inside
InviteProgressBar and apply it to the label (e.g., the "Your invite streak"
text) and to the progressbar's aria-labelledby so screen readers associate the
two (if you prefer a single static label fallback, use aria-label but prefer
aria-labelledby with useId for multiple instances).

In `@app/src/components/share/ViralInviteCard.tsx`:
- Around line 36-42: The copy callback sets a 1.8s timeout but never clears it,
causing timers to fire after unmount or stack on rapid re-triggers; update the
copy function (useCallback named copy in ViralInviteCard) to store the timeout
id in a ref (e.g., copyTimeoutRef), clear any existing timeout before creating a
new one, and clear the ref on unmount (useEffect cleanup) so setCopiedTarget is
never called after the component unmounts and multiple clicks don't stack
timers.
- Around line 87-103: The copy button currently calls copy('code', code) even
when code is empty; update the ViralInviteCard button so it is disabled when
code is falsy: guard the onClick to no-op (or remove) when !code, add disabled
and aria-disabled attributes, and adjust the className to reflect disabled
styling; ensure the copiedTarget logic only sets "Copied!" when a real code was
copied. Apply the same pattern to the link button that calls copy('link', url)
if url can be empty.

In `@app/src/pages/Home.tsx`:
- Around line 275-307: There are two visible CTAs that both navigate to
'/invites': the new gradient invite card (the button with onClick={() =>
navigate('/invites')} and the "Give OpenHuman to a friend" content) and the
existing "Invite a friend" row inside the Next steps list; remove or consolidate
one to avoid duplicate CTAs on first paint. Either delete the whole gradient
button block (the <button ...> wrapper with the "Give OpenHuman to a friend"
content) or replace the Next steps "Invite a friend" list item with this new
gradient card so only one invite CTA remains; ensure navigation still uses
navigate('/invites') and style/aria-label are preserved if you keep the gradient
version.

In `@app/src/pages/Welcome.tsx`:
- Around line 65-76: The decorative "social proof" block in Welcome.tsx (the div
with className "flex items-center justify-center gap-2 mb-6" that contains the
colored avatar spans and the "Trusted by builders worldwide" text) should be
replaced or removed: either swap the four colored span placeholders with a
concrete, verifiable signal (e.g., a real users count element, partner/logo
images, or GitHub stars component and update the accompanying text accordingly)
or remove the entire div to avoid a manufactured trust signal; update any
related accessibility text or aria-labels for the new assets and ensure
styling/layout (the inner "flex -space-x-1.5" group and the text span with
className "text-[11px] font-medium text-stone-500") remain consistent with the
page’s calm-UI design.

In `@app/src/utils/__tests__/share.test.ts`:
- Around line 86-109: Add two more tests for tryNativeShare: one "returns true
and forwards payload on successful share" that stubs navigator.share with a
vi.fn().mockResolvedValue(undefined), calls tryNativeShare with a payload (e.g.,
{ url: 'x', text: 'y' }), asserts it resolves to true and that the mock was
called with the exact payload; and one "returns false for non-AbortError
rejections" that stubs navigator.share with vi.fn().mockRejectedValue(err) where
err.name = 'NotAllowedError' (or another non-'AbortError'), asserts
tryNativeShare resolves to false and that the mock was called. Use
Object.defineProperty to set navigator.share and restore the original as in
existing tests.
- Around line 26-55: Tests for buildShareUrl and defaultInviteMessage are
incomplete: add parameterized tests covering the remaining SharePlatform values
('linkedin', 'facebook', 'reddit', 'sms') and assert their expected URL
patterns/encoded params, and add tests for defaultInviteMessage to ensure it
returns the expected invite string; update app/src/utils/__tests__/share.test.ts
to use it.each with rows for all eight platforms and expected substrings
(referencing buildShareUrl and SharePlatform) and add a separate test asserting
defaultInviteMessage's exact value or required substrings.

In `@app/src/utils/share.ts`:
- Around line 105-125: Change tryNativeShare to return a discriminated result
type instead of boolean: define type NativeShareResult = 'shared' | 'cancelled'
| 'unsupported' | 'failed' and change the function signature to
Promise<NativeShareResult>; if navigator or navigator.share is unavailable
return 'unsupported', on successful await return 'shared', if caught error has
name 'AbortError' return 'cancelled', otherwise log the error and return
'failed'; update callers like SocialShareRow.handleNativeShare to branch on
these string results so cancellation can be treated differently from a real
share or a failure.
- Around line 31-33: The share message is hardcoded in defaultInviteMessage —
route it through the app's i18n/translation layer instead. Replace the plain
template in defaultInviteMessage(code, url) with a call to the translation
utility (e.g., t('invite.message', { code, url }) or
i18n.translate('invite.message', { code, url })) so the string is localized and
uses interpolation keys; update translation resource files to include the
corresponding message key for each locale.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 29ae2616-7194-4fc0-981d-025a58c634cb

📥 Commits

Reviewing files that changed from the base of the PR and between 9d7237b and caedd98.

📒 Files selected for processing (11)
  • app/src/components/rewards/ReferralRewardsSection.tsx
  • app/src/components/share/InviteProgressBar.tsx
  • app/src/components/share/SocialShareRow.tsx
  • app/src/components/share/ViralInviteCard.tsx
  • app/src/pages/Home.tsx
  • app/src/pages/Invites.tsx
  • app/src/pages/Welcome.tsx
  • app/src/test/setup.ts
  • app/src/utils/__tests__/share.test.ts
  • app/src/utils/config.ts
  • app/src/utils/share.ts

Comment on lines +92 to +99
const shareUrl = useMemo(
() => stats?.referralLink?.trim() || stats?.referralCode?.trim() || '',
[stats?.referralLink, stats?.referralCode]
);
const shareMessage = useMemo(
() => defaultInviteMessage(stats?.referralCode?.trim() || 'OPENHUMAN', shareUrl),
[stats?.referralCode, shareUrl]
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bug: shareUrl falls back to a bare referral code, breaking social share intents.

When the backend doesn't populate stats.referralLink, shareUrl becomes the raw referralCode (e.g. "ABC123") and is then handed to ViralInviteCard.url and SocialShareRow.url. SocialShareRow interpolates it into x.com/intent/tweet?url=…, wa.me/?text=…, mailto bodies, etc., producing broken/non-clickable share targets. Invites.tsx already uses buildInviteUrl(code) for the same flow — ReferralRewardsSection should do the same so the two surfaces stay consistent.

🐛 Proposed fix
-import { defaultInviteMessage } from '../../utils/share';
+import { buildInviteUrl, defaultInviteMessage } from '../../utils/share';
@@
-  const shareUrl = useMemo(
-    () => stats?.referralLink?.trim() || stats?.referralCode?.trim() || '',
-    [stats?.referralLink, stats?.referralCode]
-  );
+  const shareUrl = useMemo(() => {
+    const link = stats?.referralLink?.trim();
+    if (link) return link;
+    const code = stats?.referralCode?.trim();
+    return code ? buildInviteUrl(code) : '';
+  }, [stats?.referralLink, stats?.referralCode]);
   const shareMessage = useMemo(
     () => defaultInviteMessage(stats?.referralCode?.trim() || 'OPENHUMAN', shareUrl),
     [stats?.referralCode, shareUrl]
   );
@@
-        <ViralInviteCard
-          code={stats.referralCode}
-          url={shareUrl || stats.referralCode}
+        <ViralInviteCard
+          code={stats.referralCode}
+          url={shareUrl}
@@
-              <SocialShareRow
-                url={shareUrl || stats.referralCode}
+              <SocialShareRow
+                url={shareUrl}
                 message={shareMessage}
                 variant="spacious"
               />

Also applies to: 141-153, 177-181

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/rewards/ReferralRewardsSection.tsx` around lines 92 - 99,
The share URL currently falls back to the raw referralCode in the useMemo that
computes shareUrl (using stats?.referralLink?.trim() ||
stats?.referralCode?.trim()), which breaks downstream ViralInviteCard.url and
SocialShareRow.url; change the fallback to construct a proper URL via
buildInviteUrl(...) instead. Locate the useMemo that defines shareUrl and
replace the fallback to stats?.referralCode?.trim() with
buildInviteUrl(stats?.referralCode?.trim() || ''), and update any other
identical fallback useMemo instances (the similar blocks around the other
occurrences referenced) so defaultInviteMessage and components receive a full
URL rather than a bare code. Ensure you import or reference buildInviteUrl where
needed and preserve trimming/empty-string guards.

Comment on lines +1 to +27
import debugFactory from 'debug';
import { useCallback, useState } from 'react';

import { copyToClipboard, shareOn, type SharePlatform, tryNativeShare } from '../../utils/share';

const log = debugFactory('share:row');

interface SocialShareRowProps {
/** Share URL (typically the invite or referral link). */
url: string;
/** Text blurb to prefill each social intent. */
message: string;
/** Dense (icon-only) vs spacious (icon + label) rendering. */
variant?: 'dense' | 'spacious';
/** Platforms to render, in the order shown. Defaults to the full set. */
platforms?: SharePlatform[];
/** Called after a share intent opens (analytics hook). */
onShare?: (platform: SharePlatform | 'native' | 'copy') => void;
}

interface PlatformMeta {
id: SharePlatform;
label: string;
bg: string;
hover: string;
icon: React.ReactNode;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm React isn't otherwise pulled into scope via tsconfig jsxImportSource or a global types augmentation.
fd -e json -i tsconfig | xargs -I{} sh -c 'echo "=== {} ==="; cat {}'
rg -nP "jsx(ImportSource|Factory)" --type=json
rg -nP "^\s*import\s+\*\s+as\s+React" app/src/components/share/SocialShareRow.tsx

Repository: tinyhumansai/openhuman

Length of output: 1729


🏁 Script executed:

cat -n app/src/components/share/SocialShareRow.tsx | head -30

Repository: tinyhumansai/openhuman

Length of output: 1203


🏁 Script executed:

cd app && npx tsc --noEmit 2>&1 | grep -A 2 -B 2 "SocialShareRow\|React.ReactNode" | head -20

Repository: tinyhumansai/openhuman

Length of output: 48


🏁 Script executed:

cd app && npx tsc --noEmit 2>&1 | head -30

Repository: tinyhumansai/openhuman

Length of output: 307


🏁 Script executed:

cat app/package.json | grep -A 2 '"react"'
rg "declare\s+global|declare\s+namespace\s+React" app/src
fd -e d.ts | xargs grep -l "namespace React" 2>/dev/null | head -5

Repository: tinyhumansai/openhuman

Length of output: 229


🏁 Script executed:

cat app/src/vite-env.d.ts
echo "---"
cat app/src/types/global.d.ts

Repository: tinyhumansai/openhuman

Length of output: 1018


Use import type for the ReactNode type reference.

While the code currently passes typecheck, the type-only usage of React.ReactNode on line 26 should follow the guideline of importing type-only imports explicitly. Import ReactNode directly and use it without the React. prefix.

Suggested change
-import { useCallback, useState } from 'react';
+import { useCallback, useState } from 'react';
+import type { ReactNode } from 'react';

Then update line 26:

-  icon: React.ReactNode;
+  icon: ReactNode;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/share/SocialShareRow.tsx` around lines 1 - 27, The
PlatformMeta interface uses the type-only React.ReactNode; change this to a type
import to follow guidelines: add "import type { ReactNode } from 'react'" at the
top and update the PlatformMeta.icon declaration to use ReactNode (remove the
React. prefix). Locate the PlatformMeta interface in SocialShareRow.tsx and
replace the type usage accordingly so only a type-only import is used.

Comment on lines +176 to +191
const handleNativeShare = useCallback(async () => {
const ok = await tryNativeShare({ title: 'OpenHuman', text: message, url });
if (ok) {
onShare?.('native');
} else {
await handleCopy();
}
}, [message, url, onShare, handleCopy]);

const handlePlatform = useCallback(
async (platform: SharePlatform) => {
await shareOn(platform, message, url);
onShare?.(platform);
},
[message, url, onShare]
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

onShare('native') fires even when the user cancels the native share sheet.

tryNativeShare deliberately returns true on AbortError (see app/src/utils/share.ts Lines 117-121, also asserted in the test suite). That collapses two distinct outcomes — successful share vs. user cancellation — into a single 'native' analytics event, which will inflate share-success metrics. If onShare is intended for analytics, consider returning a tri-state from tryNativeShare (e.g. 'shared' | 'cancelled' | 'unsupported') and only firing onShare('native') on the 'shared' branch.

Additionally, shareOn (Line 187) is awaited but its rejection isn't caught; if openUrl throws, the onShare(platform) call below never runs and the rejection becomes an unhandled promise rejection through the void handlePlatform(id) wrapper. A small try/catch around it would harden the UX.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/share/SocialShareRow.tsx` around lines 176 - 191, The
native-share flow conflates user-cancel with success and fires onShare('native')
even on AbortError; update tryNativeShare to return a tri-state (e.g. 'shared' |
'cancelled' | 'unsupported') and change handleNativeShare to only call
onShare('native') when the result === 'shared' while falling back to handleCopy
for 'unsupported' and doing nothing for 'cancelled'. Also wrap the await
shareOn(...) call inside handlePlatform in a try/catch so that if shareOn (or
openUrl) rejects you still call or safely skip onShare(platform) and avoid
unhandled promise rejections; reference functions: tryNativeShare,
handleNativeShare, handleCopy, shareOn, handlePlatform, and onShare.

Comment on lines +242 to +247
<button
type="button"
onClick={() => void handleCopy()}
className={`inline-flex items-center justify-center gap-1.5 rounded-full transition-colors ${
copied ? 'bg-sage-500 text-white' : 'bg-stone-900 text-white hover:bg-stone-800'
} ${dense ? 'h-9 px-3 text-xs font-medium' : 'h-9 px-3 text-xs font-medium'}`}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Redundant ternary on copy-button sizing.

Both branches of dense ? 'h-9 px-3 text-xs font-medium' : 'h-9 px-3 text-xs font-medium' are identical — collapse to a single class string. Also worth verifying whether the copy button was intended to also become icon-only in dense mode (the platform buttons do shrink to h-9 w-9 when dense).

♻️ Proposed simplification
-        className={`inline-flex items-center justify-center gap-1.5 rounded-full transition-colors ${
-          copied ? 'bg-sage-500 text-white' : 'bg-stone-900 text-white hover:bg-stone-800'
-        } ${dense ? 'h-9 px-3 text-xs font-medium' : 'h-9 px-3 text-xs font-medium'}`}>
+        className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-full px-3 text-xs font-medium transition-colors ${
+          copied ? 'bg-sage-500 text-white' : 'bg-stone-900 text-white hover:bg-stone-800'
+        }`}>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type="button"
onClick={() => void handleCopy()}
className={`inline-flex items-center justify-center gap-1.5 rounded-full transition-colors ${
copied ? 'bg-sage-500 text-white' : 'bg-stone-900 text-white hover:bg-stone-800'
} ${dense ? 'h-9 px-3 text-xs font-medium' : 'h-9 px-3 text-xs font-medium'}`}>
<button
type="button"
onClick={() => void handleCopy()}
className={`inline-flex h-9 items-center justify-center gap-1.5 rounded-full px-3 text-xs font-medium transition-colors ${
copied ? 'bg-sage-500 text-white' : 'bg-stone-900 text-white hover:bg-stone-800'
}`}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/components/share/SocialShareRow.tsx` around lines 242 - 247, The copy
button in SocialShareRow contains a redundant ternary for sizing; replace the
duplicated conditional in the className on the button (the expression that
currently returns 'h-9 px-3 text-xs font-medium' for both dense branches) with a
single class string to collapse the ternary, and while editing confirm whether
the copy button should mimic the other platform buttons' dense behavior (i.e.,
become icon-only with classes like 'h-9 w-9') and implement that change on the
same button if intended; locate the button by referencing SocialShareRow, the
onClick handler handleCopy, and the copied/dense props to make the update.

Comment thread app/src/pages/Invites.tsx
Comment on lines +143 to +178
const availableCode = useMemo(
() => codes.find(c => c.currentUses < c.maxUses) ?? codes[0],
[codes]
);

const convertedCount = useMemo(
() => codes.filter(c => c.currentUses >= c.maxUses).length,
[codes]
);

const heroCode = availableCode?.code ?? '';
const heroUrl = useMemo(() => buildInviteUrl(heroCode), [heroCode]);
const heroMessage = useMemo(
() => defaultInviteMessage(heroCode || 'OPENHUMAN', heroUrl),
[heroCode, heroUrl]
);

return (
<div className="min-h-full flex items-center justify-center p-4 pt-6">
<div className="max-w-md w-full space-y-4">
<div>
<div className="space-y-4">
{/* Redeem Section — shown only if user hasn't redeemed yet */}
{!hasBeenInvited && (
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 animate-fade-up">
<h2 className="text-lg font-bold mb-1">Redeem an Invite Code</h2>
<p className="text-xs opacity-70 mb-4">
Got a code from a friend? Enter it below to unlock free credits.
</p>
<div className="flex gap-2">
<input
type="text"
value={redeemInput}
onChange={e => setRedeemInput(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && handleRedeem()}
placeholder="Enter code"
className="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl font-mono text-sm tracking-wider placeholder:text-stone-500 placeholder:tracking-normal placeholder:font-sans focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all"
disabled={redeemStatus === 'loading'}
/>
<button
onClick={handleRedeem}
disabled={redeemStatus === 'loading' || !redeemInput.trim()}
className="btn-primary px-5 py-2.5 text-sm font-medium rounded-xl disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap">
{redeemStatus === 'loading' ? '...' : 'Redeem'}
</button>
</div>
{redeemStatus === 'success' && (
<p className="text-sage-500 text-xs mt-2">Invite code redeemed successfully!</p>
)}
{redeemStatus === 'error' && redeemError && (
<p className="text-coral-500 text-xs mt-2">{redeemError}</p>
)}
</div>
<div className="min-h-full p-4 pt-6 pb-10">
<div className="mx-auto max-w-xl space-y-4">
{/* Hero — shareable invite card */}
{heroCode ? (
<div className="animate-fade-up space-y-3">
<ViralInviteCard
code={heroCode}
url={heroUrl}
firstName={user?.firstName ?? undefined}
/>
<div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-soft">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-stone-400">
Share with one tap
</p>
<SocialShareRow url={heroUrl} message={heroMessage} variant="spacious" />
</div>
</div>
) : null}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Hero card can present a fully-redeemed code when no available code exists.

availableCode falls back to codes[0] when no code has remaining capacity, so the hero card + SocialShareRow will render and let the user share a code that no friend can redeem. Consider gating the hero on a truly-available code; if all are used up, showing the progress section ("you maxed out") alone reads better.

♻️ Suggested change
-  const availableCode = useMemo(
-    () => codes.find(c => c.currentUses < c.maxUses) ?? codes[0],
-    [codes]
-  );
+  const availableCode = useMemo(
+    () => codes.find(c => c.currentUses < c.maxUses),
+    [codes]
+  );

heroCode ? … will then naturally hide the hero when there's nothing shareable left.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const availableCode = useMemo(
() => codes.find(c => c.currentUses < c.maxUses) ?? codes[0],
[codes]
);
const convertedCount = useMemo(
() => codes.filter(c => c.currentUses >= c.maxUses).length,
[codes]
);
const heroCode = availableCode?.code ?? '';
const heroUrl = useMemo(() => buildInviteUrl(heroCode), [heroCode]);
const heroMessage = useMemo(
() => defaultInviteMessage(heroCode || 'OPENHUMAN', heroUrl),
[heroCode, heroUrl]
);
return (
<div className="min-h-full flex items-center justify-center p-4 pt-6">
<div className="max-w-md w-full space-y-4">
<div>
<div className="space-y-4">
{/* Redeem Section — shown only if user hasn't redeemed yet */}
{!hasBeenInvited && (
<div className="bg-white rounded-2xl shadow-soft border border-stone-200 p-6 animate-fade-up">
<h2 className="text-lg font-bold mb-1">Redeem an Invite Code</h2>
<p className="text-xs opacity-70 mb-4">
Got a code from a friend? Enter it below to unlock free credits.
</p>
<div className="flex gap-2">
<input
type="text"
value={redeemInput}
onChange={e => setRedeemInput(e.target.value.toUpperCase())}
onKeyDown={e => e.key === 'Enter' && handleRedeem()}
placeholder="Enter code"
className="flex-1 px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl font-mono text-sm tracking-wider placeholder:text-stone-500 placeholder:tracking-normal placeholder:font-sans focus:outline-none focus:ring-2 focus:ring-primary-500/50 focus:border-primary-500/50 transition-all"
disabled={redeemStatus === 'loading'}
/>
<button
onClick={handleRedeem}
disabled={redeemStatus === 'loading' || !redeemInput.trim()}
className="btn-primary px-5 py-2.5 text-sm font-medium rounded-xl disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap">
{redeemStatus === 'loading' ? '...' : 'Redeem'}
</button>
</div>
{redeemStatus === 'success' && (
<p className="text-sage-500 text-xs mt-2">Invite code redeemed successfully!</p>
)}
{redeemStatus === 'error' && redeemError && (
<p className="text-coral-500 text-xs mt-2">{redeemError}</p>
)}
</div>
<div className="min-h-full p-4 pt-6 pb-10">
<div className="mx-auto max-w-xl space-y-4">
{/* Hero — shareable invite card */}
{heroCode ? (
<div className="animate-fade-up space-y-3">
<ViralInviteCard
code={heroCode}
url={heroUrl}
firstName={user?.firstName ?? undefined}
/>
<div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-soft">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-stone-400">
Share with one tap
</p>
<SocialShareRow url={heroUrl} message={heroMessage} variant="spacious" />
</div>
</div>
) : null}
const availableCode = useMemo(
() => codes.find(c => c.currentUses < c.maxUses),
[codes]
);
const convertedCount = useMemo(
() => codes.filter(c => c.currentUses >= c.maxUses).length,
[codes]
);
const heroCode = availableCode?.code ?? '';
const heroUrl = useMemo(() => buildInviteUrl(heroCode), [heroCode]);
const heroMessage = useMemo(
() => defaultInviteMessage(heroCode || 'OPENHUMAN', heroUrl),
[heroCode, heroUrl]
);
return (
<div className="min-h-full p-4 pt-6 pb-10">
<div className="mx-auto max-w-xl space-y-4">
{/* Hero — shareable invite card */}
{heroCode ? (
<div className="animate-fade-up space-y-3">
<ViralInviteCard
code={heroCode}
url={heroUrl}
firstName={user?.firstName ?? undefined}
/>
<div className="rounded-2xl border border-stone-200 bg-white p-4 shadow-soft">
<p className="mb-2 text-[11px] font-medium uppercase tracking-wider text-stone-400">
Share with one tap
</p>
<SocialShareRow url={heroUrl} message={heroMessage} variant="spacious" />
</div>
</div>
) : null}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/pages/Invites.tsx` around lines 143 - 178, availableCode currently
falls back to codes[0], which can be fully redeemed and causes the hero
(ViralInviteCard / SocialShareRow) to render incorrectly; change availableCode
to return undefined when no code has currentUses < maxUses (i.e. remove the
fallback to codes[0]) so heroCode becomes undefined in that case, and ensure
heroUrl and heroMessage derive from heroCode so the hero conditional (heroCode ?
…) naturally prevents rendering the invite card and share row when all codes are
maxed out.

Comment thread app/src/utils/share.ts
Comment on lines +18 to +28
/**
* Build a public invite URL that friends can click from anywhere.
*
* Routes to the web onboarding flow with `?invite=CODE`; the desktop/mobile
* app also handles `openhuman://invite/CODE` for deep linking.
*/
export function buildInviteUrl(code: string): string {
const trimmed = code.trim();
if (!trimmed) return SHARE_BASE_URL;
return `${SHARE_BASE_URL}/i/${encodeURIComponent(trimmed)}`;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JSDoc doesn't match the implementation.

The doc comment says the URL routes to ?invite=CODE and that the app handles openhuman://invite/CODE deep links, but buildInviteUrl actually produces ${SHARE_BASE_URL}/i/${encodeURIComponent(trimmed)} — a path segment, not a query param, and there is no deep-link handling here. Either fix the doc or extend the implementation to match the documented contract (the deep-link claim in particular could be confusing for downstream consumers).

📝 Suggested doc rewrite
 /**
  * Build a public invite URL that friends can click from anywhere.
  *
- * Routes to the web onboarding flow with `?invite=CODE`; the desktop/mobile
- * app also handles `openhuman://invite/CODE` for deep linking.
+ * Produces `${SHARE_BASE_URL}/i/<code>` where the web onboarding flow picks
+ * up the trailing path segment as the invite code.
  */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/utils/share.ts` around lines 18 - 28, The JSDoc for buildInviteUrl is
inaccurate: it claims the invite is a ?invite=CODE query and mentions
openhuman:// deep links, but the function (buildInviteUrl) actually returns a
path-style URL using SHARE_BASE_URL + '/i/' + encodeURIComponent(trimmed).
Update the JSDoc to describe the actual behavior (trims the code, URL-encodes
it, and returns SHARE_BASE_URL/i/CODE) and remove the misleading deep-link claim
(or, if you prefer to implement deep-linking, change buildInviteUrl to also
return the deep link or provide a separate function). Ensure the doc references
buildInviteUrl and SHARE_BASE_URL and accurately documents trimming and
encoding.

Comment thread app/src/utils/share.ts
Comment on lines +24 to +28
export function buildInviteUrl(code: string): string {
const trimmed = code.trim();
if (!trimmed) return SHARE_BASE_URL;
return `${SHARE_BASE_URL}/i/${encodeURIComponent(trimmed)}`;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Empty/whitespace code silently degrades to the bare base URL.

When code is empty or whitespace, buildInviteUrl returns SHARE_BASE_URL with no path. In app/src/pages/Invites.tsx Line 153 (heroCode = availableCode?.code ?? ''), this means a user with no available invite codes still sees a "share" affordance whose URL is just the homepage — they'll happily share a non-attributable link. Consider returning '' (or throwing) and having callers gate the share UI on a non-empty code, so the share row doesn't render at all in the no-code case.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/utils/share.ts` around lines 24 - 28, The buildInviteUrl function
silently returns SHARE_BASE_URL when code is empty/whitespace; change
buildInviteUrl (exported function buildInviteUrl) to return an empty string (or
throw) when trimmed is falsy instead of returning SHARE_BASE_URL, so callers can
detect "no code" (e.g., heroCode = availableCode?.code ?? '') and avoid
rendering the share affordance; update any call sites that currently assume a
URL (particularly the component that renders the share row) to gate rendering on
a non-empty return from buildInviteUrl.

Comment thread app/src/utils/share.ts
`mailto:?subject=${encodeURIComponent('Try OpenHuman with me')}&body=${encodeURIComponent(
`${text}\n\n${url}`
)}`,
sms: (text, url) => `sms:?&body=${encodeURIComponent(`${text} ${url}`)}`,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

What is the cross-platform compatible URI scheme for SMS share links with a prefilled body on iOS Safari and Android Chrome in 2026?

💡 Result:

There is no single cross-platform compatible URI scheme for SMS share links with a prefilled body that works identically on both iOS Safari and Android Chrome without detection or JavaScript. Official Apple documentation states that the sms: scheme does not support message body text, only phone numbers. However, in practice, undocumented extensions allow prefill on both platforms using platform-specific syntax. For a prefilled body with no recipient (share link use case): - Android Chrome: sms://?&body=message (or sms:?body=message) - iOS Safari: sms://?&body=message The most commonly cited cross-compatible format from developer testing (up to iOS 18/iOS 26 and Android 13 in 2025 reports) is <a href="sms://;?&body=Hello%20World">, which opens the SMS composer with prefilled body and prompts for recipient on both. To make it truly cross-platform, use JavaScript to detect the platform via user agent and construct the URL dynamically: var ua = navigator.userAgent.toLowerCase; var separator = (ua.indexOf('iphone') > -1 || ua.indexOf('ipad') > -1) ? ';' : '?'; // or '&' for newer iOS var url = 'sms:' + separator + 'body=' + encodeURIComponent(message); location.href = url; Or with // for better Chrome Android support: sms:// + separator + 'body='... Note: Prefill behavior can vary by OS version, default SMS app (e.g., Google Messages vs Samsung), and whether the recipient is in contacts. Always URL-encode the body. No changes noted for 2026; RCS uses similar sms: with body= but is not standard SMS.

Citations:


🏁 Script executed:

cat -n app/src/utils/share.ts | head -60

Repository: tinyhumansai/openhuman

Length of output: 2869


🏁 Script executed:

cat -n app/src/utils/share.ts | tail -20

Repository: tinyhumansai/openhuman

Length of output: 685


🏁 Script executed:

rg -i "iphone|ipad|ios|android|userAgent|platform" app/src/utils/share.ts -A 3 -B 3

Repository: tinyhumansai/openhuman

Length of output: 1474


🏁 Script executed:

rg -i "sms" app/src/ --type ts --type tsx -B 3 -A 3

Repository: tinyhumansai/openhuman

Length of output: 94


🏁 Script executed:

rg -i "sms" app/src/ -t ts -t tsx -B 3 -A 3

Repository: tinyhumansai/openhuman

Length of output: 94


🏁 Script executed:

rg -i "sms" app/src/ -B 3 -A 3

Repository: tinyhumansai/openhuman

Length of output: 1351


🏁 Script executed:

grep -r "navigator.userAgent\|iphone\|ipad\|android" app/src/utils/ 2>/dev/null

Repository: tinyhumansai/openhuman

Length of output: 48


🏁 Script executed:

rg "navigator.userAgent\|window.location\|isTauri\|platform" app/src/utils/ --type ts -B 2 -A 2

Repository: tinyhumansai/openhuman

Length of output: 48


🏁 Script executed:

grep -r "iOS\|Android\|Safari\|Chrome" app/src/ 2>/dev/null | head -20

Repository: tinyhumansai/openhuman

Length of output: 1152


Update SMS URI scheme for iOS compatibility.

The current format sms:?&body=... works on Android but fails on iOS — the ?& separator causes the body parameter to be ignored in iOS Safari, leaving users with an empty SMS draft. Change to sms:?body=... (removing the &) for better cross-platform compatibility, or implement UA detection to use sms:;body=... on iOS and sms:?body=... on Android if broader testing reveals the simpler format still has edge cases.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/utils/share.ts` at line 51, The sms share URI currently built in the
sms property (sms: (text, url) => `sms:?&body=...`) uses the `?&` separator
which breaks body parsing on iOS; update the sms builder in the sms function to
use `sms:?body=${encodeURIComponent(`${text} ${url}`)}` (remove the extra `&`)
for cross-platform compatibility, or alternatively implement simple UA detection
inside the same sms function to return `sms:;body=...` for iOS user agents and
`sms:?body=...` for others if you need to handle edge cases.

@jwalin-shah
Copy link
Copy Markdown
Contributor Author

Closing — superseded by #871 (15e5d65) which shipped equivalent share-flow functionality with cleaner refactor. Rebase produced extensive conflicts in the same file #871 already touched.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants