diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx index 545480c295..65189eea31 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.test.tsx @@ -114,6 +114,11 @@ const mocks = vi.hoisted(() => ({ taggedFiltersAskedFor: [] as any[], taggedHasNextPage: false, fetchNextTaggedPage: vi.fn(), + boundedPagingOverride: null as { + autoPages: boolean; + stoppedShort: boolean; + keepLooking: () => void; + } | null, entityQueryHasNextPage: false, /** The hub's claims query (the All tab) is still in flight. */ entityQueryLoading: false, @@ -482,6 +487,16 @@ vi.mock('~/core/debates/tagged-claims', async importOriginal => ({ }, })); +vi.mock('~/core/debates/matchmaking/use-bounded-paging', async importOriginal => { + const original = await importOriginal(); + + return { + ...original, + useBoundedPaging: (options: Parameters[0]) => + mocks.boundedPagingOverride ?? original.useBoundedPaging(options), + }; +}); + const HYDRATION_ERROR = new Error('hydration exploded'); // The opponent's claims are hydrated from the graph by id, through the picker's narrow projection. @@ -877,6 +892,7 @@ beforeEach(() => { mocks.taggedFiltersAskedFor = []; mocks.taggedHasNextPage = false; mocks.fetchNextTaggedPage = vi.fn(); + mocks.boundedPagingOverride = null; mocks.entityHydrationErrorFor = null; mocks.savedClaimsLoading = false; mocks.savedClaimsError = null; @@ -1423,6 +1439,27 @@ describe('DebateRematchPageClient', () => { expect(toggle.previousElementSibling?.className).toContain('flex-1'); }); + it('attributes shared filters and switches to rematch rather than the debate hub', async () => { + render(); + await showExplore(); + + expect(screen.getByRole('button', { name: 'All claims' })).toHaveAttribute( + 'data-geo-analytics-label', + 'Debate rematch Claims source filter' + ); + expect(screen.getByRole('button', { name: /Any space/ })).toHaveAttribute( + 'data-geo-analytics-label', + 'Debate rematch Space filter' + ); + expect(screen.getByRole('button', { name: /Any topic/ })).toHaveAttribute( + 'data-geo-analytics-label', + 'Debate rematch Topic filter' + ); + const toggle = screen.getByRole('switch', { name: 'Hide my positions' }); + expect(toggle).toHaveAttribute('data-geo-analytics-label', 'Debate rematch Hide my positions'); + expect(toggle).toHaveAttribute('data-geo-analytics-intent', 'filter_debate_rematch'); + }); + // A fixed order, so a source that appears doesn't reshuffle the ones already in the menu. it('offers the sources in a fixed order, Recommended first', async () => { curatedPage(); @@ -1969,6 +2006,16 @@ describe('DebateRematchPageClient', () => { expect(screen.queryByRole('button', { name: 'Keep looking' })).toBeNull(); }); + it('keeps the same rematch attribution when Keep looking appears below existing rows', async () => { + mocks.boundedPagingOverride = { autoPages: false, stoppedShort: true, keepLooking: vi.fn() }; + render(); + await showAllClaims(); + + const keepLooking = screen.getByRole('button', { name: 'Keep looking' }); + expect(keepLooking).toHaveAttribute('data-geo-analytics-label', 'Debate rematch Keep looking'); + expect(keepLooking).toHaveAttribute('data-geo-analytics-intent', 'debate_rematch_action'); + }); + it('leaves the sentinel out once there is no page left to fetch', async () => { render(); await showAllClaims(); diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx index 2c8a8d71d6..72b365bc24 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-page-client.tsx @@ -2129,6 +2129,7 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) {
{ @@ -2176,13 +2178,18 @@ export function DebateRematchPageClient({ sessionId }: { sessionId: string }) { // change a single row under it. Never on "My positions" either, which is the list // it would empty. tab === 'explore' && source !== 'mine' ? ( - + ) : null } leading={ tab === 'explore' ? ( - Keep looking + + Keep looking +
) : null} diff --git a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx index c0ba28bc09..5d24063871 100644 --- a/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx +++ b/apps/web/app/space/[id]/(space)/debates/rematches/[sessionId]/rematch-voice.test.tsx @@ -68,6 +68,7 @@ const mocks = vi.hoisted(() => ({ microphoneTrack: undefined as { track: Record } | undefined, useKrispNoiseFilter: vi.fn(() => ({ setNoiseFilterEnabled: vi.fn(() => Promise.resolve()) })), capture: vi.fn(), + personProfileOpened: vi.fn(), openSidePanel: vi.fn(), spaceLookups: [] as Array, /** @@ -212,6 +213,7 @@ vi.mock('~/design-system/avatar', () => ({ vi.mock('~/core/analytics', () => ({ capture: (...args: unknown[]) => mocks.capture(...args), + personProfileOpened: (...args: unknown[]) => mocks.personProfileOpened(...args), })); vi.mock('~/core/hooks/use-space', () => ({ @@ -398,6 +400,7 @@ beforeEach(() => { mocks.microphoneTrack = undefined; mocks.useKrispNoiseFilter.mockClear(); mocks.capture.mockReset(); + mocks.personProfileOpened.mockReset(); mocks.openSidePanel.mockReset(); mocks.spaceLookups = []; mocks.opponentSpace = { topicId: null, entity: { id: 'them-home' } }; diff --git a/apps/web/atoms/index.ts b/apps/web/atoms/index.ts index 44cb35390d..3b6e36cf42 100644 --- a/apps/web/atoms/index.ts +++ b/apps/web/atoms/index.ts @@ -84,7 +84,12 @@ export const spaceSidebarHasContentAtom = atom(null); * page they should open the comments beside what you're reading rather than * navigate away from it. */ -export const entityCommentsPanelAtom = atom<{ entityId: string; spaceId: string } | null>(null); +export const entityCommentsPanelAtom = atom<{ + entityId: string; + spaceId: string; + /** Logical graph type retained so comments created from the global panel are attributed correctly. */ + targetEntityType?: string; +} | null>(null); export type DebatesHubTab = 'requests' | 'lobby' | 'explore' | 'positions' | 'people'; diff --git a/apps/web/core/analytics.test.ts b/apps/web/core/analytics.test.ts index a9c7c0ac5b..9029a3be87 100644 --- a/apps/web/core/analytics.test.ts +++ b/apps/web/core/analytics.test.ts @@ -223,10 +223,14 @@ describe('analytics', () => { const { browseModeToggled: browse, commentCreated, + commentEdited, editModeToggled: edit, + personProfileOpened, personalSpaceViewed, + profileUpdated, publishedEdit, reviewChangesOpened, + signupCompleted, upvoted: up, voteCast: vote, } = await import('./analytics'); @@ -236,9 +240,13 @@ describe('analytics', () => { edit({ space_id: 'space-1' }); browse({ space_id: 'space-1' }); personalSpaceViewed('personal-space-1'); + personProfileOpened('profile-space-1', 'person-1', { interaction_surface: 'claim_vote_list' }); reviewChangesOpened({ space_id: 'space-1' }); publishedEdit({ space_id: 'space-1' }); + profileUpdated('person-1', 'profile-space-1'); commentCreated('comment-1', 'claim-1', { space_id: 'space-1' }); + commentEdited('comment-1', 'claim-1', { space_id: 'space-1' }); + signupCompleted('newsletter', { signup_surface: 'explore_email_capture' }); expect(upvoted).toHaveBeenCalledWith({ entity_id: 'entity-1' }); expect(voteCast).toHaveBeenCalledWith('none', { entity_id: 'entity-1' }); @@ -260,6 +268,21 @@ describe('analytics', () => { source: 'publishing', space_id: 'space-1', }); + expect(capture).toHaveBeenCalledWith('graph_relationship_followed', { + app: 'genesis', + source: 'person_profile', + entity_id: 'person-1', + graph_entity_type: 'person', + profile_space_id: 'profile-space-1', + interaction_surface: 'claim_vote_list', + }); + expect(capture).toHaveBeenCalledWith('published_edit', { + app: 'genesis', + source: 'profile_editor', + content_id: 'person-1', + content_type: 'profile', + space_id: 'profile-space-1', + }); expect(capture).toHaveBeenCalledWith('comment_created', { app: 'genesis', source: 'commenting', @@ -268,5 +291,78 @@ describe('analytics', () => { target_id: 'claim-1', space_id: 'space-1', }); + expect(capture).toHaveBeenCalledWith('content_edited', { + app: 'genesis', + source: 'commenting', + content_id: 'comment-1', + content_type: 'comment', + target_type: 'entity', + target_id: 'claim-1', + space_id: 'space-1', + }); + expect(capture).toHaveBeenCalledWith('signup_completed', { + app: 'genesis', + source: 'signup_form', + form_type: 'newsletter', + signup_surface: 'explore_email_capture', + }); + }); + + it('keeps product actions best-effort when the analytics runtime throws', async () => { + window.lytics = { + capture: vi.fn(() => { + throw new Error('collector unavailable'); + }), + }; + + const { personProfileOpened } = await import('./analytics'); + + expect(() => personProfileOpened('profile-space-1', 'person-1')).not.toThrow(); + }); + + it('attributes a profile entity fallback as its personal space', async () => { + const capture = vi.fn(); + window.lytics = { capture }; + + const { personProfileOpened } = await import('./analytics'); + + personProfileOpened('profile-space-1', 'profile-space-1', { interaction_surface: 'claim_vote_list' }); + + expect(capture).toHaveBeenCalledWith('graph_relationship_followed', { + app: 'genesis', + source: 'person_profile', + entity_id: 'profile-space-1', + graph_entity_type: 'personal_space', + profile_space_id: 'profile-space-1', + interaction_surface: 'claim_vote_list', + }); + }); + + it('recognizes dashed and differently cased forms of the same personal-space id', async () => { + const capture = vi.fn(); + window.lytics = { capture }; + + const { personProfileOpened } = await import('./analytics'); + + personProfileOpened('4C81561D-1F95-4131-9CDD-DD20AB831BA2', '4c81561d1f9541319cdddd20ab831ba2'); + + expect(capture).toHaveBeenCalledWith('graph_relationship_followed', { + app: 'genesis', + source: 'person_profile', + entity_id: '4C81561D-1F95-4131-9CDD-DD20AB831BA2', + graph_entity_type: 'personal_space', + profile_space_id: '4C81561D-1F95-4131-9CDD-DD20AB831BA2', + }); + }); + + it('does not attribute a profile open to a pending personal-space sentinel', async () => { + const capture = vi.fn(); + window.lytics = { capture }; + + const { personProfileOpened } = await import('./analytics'); + + personProfileOpened('pending:0x123', null, { interaction_surface: 'comment_author' }); + + expect(capture).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/core/analytics.ts b/apps/web/core/analytics.ts index 2f0019bad1..aec6a22bf7 100644 --- a/apps/web/core/analytics.ts +++ b/apps/web/core/analytics.ts @@ -1,5 +1,8 @@ 'use client'; +import { ID } from '~/core/id'; +import { isPendingPersonalSpaceId } from '~/core/state/pending-personal-space'; + export type AnalyticsProperties = Record; type AnalyticsIdentity = string | number | AnalyticsProperties; @@ -270,6 +273,31 @@ export function personalSpaceViewed(personalSpaceId: string, properties: Analyti }); } +export function graphRelationshipFollowed(entityId: string, properties: AnalyticsProperties = {}) { + capture('graph_relationship_followed', { + source: 'graph', + entity_id: entityId, + ...properties, + }); +} + +export function personProfileOpened( + profileSpaceId: string, + personEntityId?: string | null, + properties: AnalyticsProperties = {} +) { + if (isPendingPersonalSpaceId(profileSpaceId)) return; + + const resolvedPersonEntityId = personEntityId && !ID.equals(personEntityId, profileSpaceId) ? personEntityId : null; + + graphRelationshipFollowed(resolvedPersonEntityId ?? profileSpaceId, { + source: 'person_profile', + graph_entity_type: resolvedPersonEntityId ? 'person' : 'personal_space', + profile_space_id: profileSpaceId, + ...properties, + }); +} + export function reviewChangesOpened(properties: AnalyticsProperties = {}) { capture('review_changes_opened', { source: 'review_changes', @@ -284,6 +312,16 @@ export function publishedEdit(properties: AnalyticsProperties = {}) { }); } +export function profileUpdated(profileEntityId: string, spaceId: string, properties: AnalyticsProperties = {}) { + publishedEdit({ + source: 'profile_editor', + content_id: profileEntityId, + content_type: 'profile', + space_id: spaceId, + ...properties, + }); +} + export function commentCreated(commentId: string, targetEntityId: string, properties: AnalyticsProperties = {}) { capture('comment_created', { source: 'commenting', @@ -294,6 +332,25 @@ export function commentCreated(commentId: string, targetEntityId: string, proper }); } +export function commentEdited(commentId: string, targetEntityId: string, properties: AnalyticsProperties = {}) { + capture('content_edited', { + source: 'commenting', + content_id: commentId, + content_type: 'comment', + target_type: 'entity', + target_id: targetEntityId, + ...properties, + }); +} + +export function signupCompleted(formType: string, properties: AnalyticsProperties = {}) { + capture('signup_completed', { + source: 'signup_form', + form_type: formType, + ...properties, + }); +} + export function signedUp(user: AnalyticsIdentity, properties: AnalyticsProperties = {}) { callOrQueue({ method: 'signedUp', @@ -441,6 +498,17 @@ function flushPendingCalls() { } function invokeRuntime(call: PendingCall) { + try { + return invokeRuntimeUnsafe(call); + } catch (error) { + // Analytics is observational. A collector/runtime failure must not break the product action + // that emitted the event, and retrying the same broken call would also stall the pending queue. + console.error('Analytics runtime call failed:', error); + return true; + } +} + +function invokeRuntimeUnsafe(call: PendingCall) { const analytics = analyticsRuntime(); if (!analytics) { diff --git a/apps/web/core/claims/browse/claim-page-view.tsx b/apps/web/core/claims/browse/claim-page-view.tsx index 1078a4c853..373e2ff61e 100644 --- a/apps/web/core/claims/browse/claim-page-view.tsx +++ b/apps/web/core/claims/browse/claim-page-view.tsx @@ -467,7 +467,7 @@ function ClaimTabPanel({ viewerSpaceId={summary.viewerSpaceId} isViewerResponseLoading={summary.isViewerResponseLoading} > - + ); diff --git a/apps/web/core/claims/browse/claim-position-comment.tsx b/apps/web/core/claims/browse/claim-position-comment.tsx index 31f862de90..8de24180b3 100644 --- a/apps/web/core/claims/browse/claim-position-comment.tsx +++ b/apps/web/core/claims/browse/claim-position-comment.tsx @@ -66,7 +66,10 @@ export function ClaimPositionCommentControl({ const [actionsBelow, setActionsBelow] = React.useState(false); const composerRef = React.useRef(null); const textareaRef = React.useRef(null); - const { publishComment: submitComment } = usePublishComment(entityId, spaceId); + const { publishComment: submitComment } = usePublishComment(entityId, spaceId, { + targetEntityType: 'claim', + interactionSurface: 'claim_position_explanation', + }); const closeComposer = React.useCallback(() => { setPromptedPosition(null); diff --git a/apps/web/core/claims/browse/claim-provenance.tsx b/apps/web/core/claims/browse/claim-provenance.tsx index 217054ba44..d9adc3e8a1 100644 --- a/apps/web/core/claims/browse/claim-provenance.tsx +++ b/apps/web/core/claims/browse/claim-provenance.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; +import { personProfileOpened } from '~/core/analytics'; import { AUTHORS_PROPERTY_ID, DEBATE_CLAIMS_PROPERTY_ID, @@ -134,7 +135,15 @@ export function ClaimProvenance({ nothing is worse than plain text. `whitespace-nowrap` keeps a two-word name from breaking across lines. */} {speaker.profile?.profileLink ? ( - + + personProfileOpened(speaker.profile!.spaceId, speaker.profile!.id, { + interaction_surface: 'claim_provenance', + }) + } + className="whitespace-nowrap text-text hover:underline" + > {speaker.profile.name} ) : ( diff --git a/apps/web/core/claims/browse/claim-side-responders.tsx b/apps/web/core/claims/browse/claim-side-responders.tsx index b54954a80e..22c0afeab4 100644 --- a/apps/web/core/claims/browse/claim-side-responders.tsx +++ b/apps/web/core/claims/browse/claim-side-responders.tsx @@ -152,7 +152,12 @@ function ResponderList({ spaceIds, label, totalCount }: { spaceIds: string[]; la ) : ( sortedProfiles.map(profile => ( - + )) )} diff --git a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx index 462e752d88..954f213704 100644 --- a/apps/web/core/debates/browse/debate-claim-ticker.test.tsx +++ b/apps/web/core/debates/browse/debate-claim-ticker.test.tsx @@ -69,6 +69,8 @@ vi.mock('~/core/hooks/use-privy-sign-in', () => ({ usePrivySignIn: () => vi.fn() // The card's header names its speaker and links to them, which reaches for the side panel and for // the space behind the profile. Neither is what these tests are about. const openSidePanel = vi.fn(); +const analyticsMocks = vi.hoisted(() => ({ personProfileOpened: vi.fn() })); +vi.mock('~/core/analytics', () => ({ personProfileOpened: analyticsMocks.personProfileOpened })); vi.mock('~/core/hooks/use-entity-side-panel', () => ({ useEntitySidePanel: () => ({ openSidePanel, closeSidePanel: vi.fn(), sidePanelTarget: null }), })); @@ -159,6 +161,7 @@ describe('DebateClaimTickerCard', () => { // The same link as the name in the corner of the tile, and the same person. it('opens the speaker from the card, the way the tile corner does', () => { openSidePanel.mockClear(); + analyticsMocks.personProfileOpened.mockClear(); renderCard(); fireEvent.click(screen.getByRole('button', { name: 'Peter Feldip' })); @@ -166,6 +169,14 @@ describe('DebateClaimTickerCard', () => { expect(openSidePanel).toHaveBeenCalledWith(`page-${SPEAKER.profile_space_id}`, SPEAKER.profile_space_id, false, { forceRequestedSpace: true, }); + expect(analyticsMocks.personProfileOpened).toHaveBeenCalledWith( + SPEAKER.profile_space_id, + `page-${SPEAKER.profile_space_id}`, + { + interaction_surface: 'debate_media', + navigation_mode: 'entity_side_panel', + } + ); }); // The video behind is one big play/pause button. diff --git a/apps/web/core/debates/browse/debate-claims-panel.tsx b/apps/web/core/debates/browse/debate-claims-panel.tsx index 046c71a07e..0f437610ce 100644 --- a/apps/web/core/debates/browse/debate-claims-panel.tsx +++ b/apps/web/core/debates/browse/debate-claims-panel.tsx @@ -150,7 +150,14 @@ export function DebateClaimsPanel({ debate, onClose }: { debate: Debate; onClose Claims · {claims.totalCount} - diff --git a/apps/web/core/debates/browse/debate-feed.tsx b/apps/web/core/debates/browse/debate-feed.tsx index f19a101000..f33dc71b40 100644 --- a/apps/web/core/debates/browse/debate-feed.tsx +++ b/apps/web/core/debates/browse/debate-feed.tsx @@ -381,7 +381,13 @@ export function DebatesBrowseFeed({ ) : openPanel === 'comments' && activeDebate ? ( // Keyed so scrolling to the next debate resets the panel rather than // carrying a half-typed reply across to a different debate's thread. - + ) : null; // Keep the feed in the same tree position whether or not a side panel is open, so @@ -591,6 +597,8 @@ function DebateTitleHeader({ @@ -28,7 +36,7 @@ afterEach(() => { function renderHost() { const store = createStore(); - store.set(entityCommentsPanelAtom, { entityId: 'entity-1', spaceId: 'space-1' }); + store.set(entityCommentsPanelAtom, { entityId: 'entity-1', spaceId: 'space-1', targetEntityType: 'claim' }); const view = render(
Page behind the panel
@@ -39,6 +47,15 @@ function renderHost() { } describe('EntityCommentsPanelHost', () => { + it('retains the target type for comment analytics inside the global panel', () => { + renderHost(); + + expect(screen.getByText('Comments for entity-1').closest('aside')).toHaveAttribute( + 'data-target-entity-type', + 'claim' + ); + }); + it('closes when the reader clicks the page behind it', () => { const { store } = renderHost(); expect(screen.getByText('Comments for entity-1')).toBeInTheDocument(); diff --git a/apps/web/partials/comments/entity-comments-panel-host.tsx b/apps/web/partials/comments/entity-comments-panel-host.tsx index 8e0f002155..f0c8119e1b 100644 --- a/apps/web/partials/comments/entity-comments-panel-host.tsx +++ b/apps/web/partials/comments/entity-comments-panel-host.tsx @@ -68,6 +68,7 @@ export function EntityCommentsPanelHost() { , diff --git a/apps/web/partials/comments/entity-comments-panel.tsx b/apps/web/partials/comments/entity-comments-panel.tsx index 85ca058b0e..209696299a 100644 --- a/apps/web/partials/comments/entity-comments-panel.tsx +++ b/apps/web/partials/comments/entity-comments-panel.tsx @@ -31,11 +31,13 @@ export function EntityCommentsPanel({ spaceId, onClose, presentation = 'docked', + targetEntityType = 'entity', }: { entityId: string; spaceId: string; onClose: () => void; presentation?: 'docked' | 'overlay'; + targetEntityType?: string; }) { // Same arguments as the host's own count query, so posting here updates it. const { totalCount } = useComments({ entityId, spaceId }); @@ -113,13 +115,20 @@ export function EntityCommentsPanel({ Comments · {totalCount} -
- +
diff --git a/apps/web/partials/entity-page/custom-browse-view.ts b/apps/web/partials/entity-page/custom-browse-view.ts index c23da5ebcd..6edc5d614e 100644 --- a/apps/web/partials/entity-page/custom-browse-view.ts +++ b/apps/web/partials/entity-page/custom-browse-view.ts @@ -2,6 +2,7 @@ import { SystemIds } from '@geoprotocol/geo-sdk/lite'; import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; import { TOPIC_TYPE_ID } from '~/core/constants'; +import { isDebateEntity } from '~/core/debates/is-debate-entity'; import { ID } from '~/core/id'; import type { Space } from '~/core/io/dto/spaces'; import { Spaces } from '~/core/utils/space'; @@ -85,6 +86,25 @@ function viewFromTypes(entity: { types: { id: string }[] }): 'claim' | 'topic' | return null; } +export type CommentTargetEntityType = 'claim' | 'topic' | 'debate' | 'entity'; + +/** + * The logical entity type attached to comment analytics when a typed entity falls back to the + * generic footer (for example while editing). Browse-specific views pass the same values directly, + * so this keeps both render paths in one event family. + */ +export function commentTargetEntityType( + entity: { types: { id: string }[] } | null | undefined +): CommentTargetEntityType { + if (!entity) return 'entity'; + if (isDebateEntity(entity.types)) return 'debate'; + + const view = viewFromTypes(entity); + if (view === 'claim' || view === 'topic') return view; + + return 'entity'; +} + /** * Whether the space has to be read before this entity's view is known. * diff --git a/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx b/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx index 20ff8eb5f5..11b35b37e1 100644 --- a/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx +++ b/apps/web/partials/entity-page/entity-page-body.side-panel.test.tsx @@ -8,11 +8,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CLAIM_TYPE_ID } from '~/core/claims/ontology'; import { TOPIC_TYPE_ID } from '~/core/constants'; +import { DEBATE_TYPE_ID } from '~/core/debates/ontology'; import { EntityPageBody } from './entity-page-body'; const mocks = vi.hoisted(() => ({ actions: null as Record | null, + commentSection: null as Record | null, cover: null as Record | null, entity: { id: 'entity-1', types: [] as { id: string }[] }, heading: null as Record | null, @@ -96,7 +98,12 @@ vi.mock('~/partials/entity-page/backlinks-client-container', () => ({ BacklinksC vi.mock('~/partials/entity-page/type-schema-inline', () => ({ TypeSchemaInline: () => null })); vi.mock('~/partials/entity-page/entity-page-header', () => ({ EntityPageHeader: () => null })); vi.mock('~/partials/editor/editor', () => ({ Editor: () => null })); -vi.mock('~/partials/comments/comments-section', () => ({ CommentSection: () => null })); +vi.mock('~/partials/comments/comments-section', () => ({ + CommentSection: (props: Record) => { + mocks.commentSection = props; + return null; + }, +})); vi.mock('~/core/claims/browse/claim-page-view', () => ({ CLAIM_PAGE_CONTENT_INSET_CLASS: 'claim-content-inset', CLAIM_PAGE_CONTENT_MAX_WIDTH: 720, @@ -129,6 +136,7 @@ function renderPanel(overrides?: { isRelationPage?: boolean; previewName?: strin beforeEach(() => { mocks.actions = null; + mocks.commentSection = null; mocks.cover = null; mocks.entity = { id: 'entity-1', types: [] }; mocks.heading = null; @@ -184,6 +192,18 @@ describe('EntityPageBody relation side panel', () => { expect(mocks.actions).toMatchObject({ isVoteable: true }); }); + it('identifies debate comments from the generic entity footer', () => { + mocks.entity = { id: 'entity-1', types: [{ id: DEBATE_TYPE_ID }] }; + + renderPanel(); + + expect(mocks.commentSection).toMatchObject({ + entityId: 'entity-1', + spaceId: 'space-1', + targetEntityType: 'debate', + }); + }); + it('puts profile actions beside the name and hides Person and Space types', () => { const personType = { id: SystemIds.PERSON_TYPE }; mocks.entity = { id: 'entity-1', types: [personType] }; diff --git a/apps/web/partials/entity-page/entity-page-body.tsx b/apps/web/partials/entity-page/entity-page-body.tsx index 247128d3dd..349653e1bb 100644 --- a/apps/web/partials/entity-page/entity-page-body.tsx +++ b/apps/web/partials/entity-page/entity-page-body.tsx @@ -23,7 +23,11 @@ import { Spacer } from '~/design-system/spacer'; import { CommentSection } from '~/partials/comments/comments-section'; import { Editor } from '~/partials/editor/editor'; import { AutomaticModeToggle } from '~/partials/entity-page/automatic-mode-toggle'; -import { type CustomBrowseView, customBrowseView, needsSpaceForView } from '~/partials/entity-page/custom-browse-view'; +import { + commentTargetEntityType, + customBrowseView, + needsSpaceForView, +} from '~/partials/entity-page/custom-browse-view'; import { EditableHeading } from '~/partials/entity-page/editable-entity-header'; import { EntityBacklinks } from '~/partials/entity-page/entity-backlinks'; import { EntityPageActions } from '~/partials/entity-page/entity-page-actions'; @@ -97,12 +101,14 @@ function EditorFooter({ entityId, spaceId, variant, + targetEntityType, belowBodySlot, hideProperties = false, }: { entityId: string; spaceId: string; variant: EntityPageBodyProps['variant']; + targetEntityType: string; belowBodySlot?: React.ReactNode; hideProperties?: boolean; }) { @@ -125,7 +131,7 @@ function EditorFooter({ ) : null} - + ); } @@ -142,7 +148,7 @@ function EditorFooter({ * derived across every space either way, so this is about consistency with the controls the pages * render rather than about reaching a type a scoped read would miss. */ -function useCustomBrowseView(entityId: string, spaceId: string, isEditing: boolean): CustomBrowseView { +function useCustomBrowseView(entityId: string, spaceId: string, isEditing: boolean) { const { entity, isLoading } = useQueryEntity({ id: entityId }); /* @@ -159,20 +165,24 @@ function useCustomBrowseView(entityId: string, spaceId: string, isEditing: boole */ const { space, isLoading: isLoadingSpace } = useSpace(needsSpaceForView({ entity, isEditing }) ? spaceId : undefined); - return customBrowseView({ - entityId, + return { + customView: customBrowseView({ + entityId, + entity, + isLoadingEntity: isLoading, + space, + isLoadingSpace, + isEditing, + }), entity, - isLoadingEntity: isLoading, - space, - isLoadingSpace, - isEditing, - }); + }; } export function EntityPageBody(props: EntityPageBodyProps) { const { entityId, spaceId, initialTabRelations, tabEntities } = props; const isEditing = useUserIsEditing(spaceId); - const customView = useCustomBrowseView(entityId, spaceId, isEditing); + const { customView, entity } = useCustomBrowseView(entityId, spaceId, isEditing); + const targetEntityType = commentTargetEntityType(entity); const previewImageUrl = props.variant === 'sidePanel' ? props.previewImageUrl : undefined; const entityMediaUrl = useEntityMediaUrl(entityId, spaceId); @@ -403,6 +413,7 @@ export function EntityPageBody(props: EntityPageBodyProps) { entityId={entityId} spaceId={spaceId} variant="sidePanel" + targetEntityType={targetEntityType} belowBodySlot={belowBodySlot} hideProperties={hideProperties} /> @@ -454,6 +465,7 @@ export function EntityPageBody(props: EntityPageBodyProps) { entityId={entityId} spaceId={spaceId} variant="route" + targetEntityType={targetEntityType} belowBodySlot={belowBodySlot} hideProperties={hideProperties} /> diff --git a/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx b/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx index a038302627..dcd056367f 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.batch.test.tsx @@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => ({ submitResponse: vi.fn(), responderAvatarProps: [] as unknown[], getProfiles: vi.fn(), + personProfileOpened: vi.fn(), })); vi.mock('@geogenesis/auth', () => ({ @@ -40,6 +41,7 @@ vi.mock('@geogenesis/auth', () => ({ vi.mock('~/core/analytics', () => ({ downvoted: vi.fn(), + personProfileOpened: mocks.personProfileOpened, trackPrivyAuth: vi.fn(), upvoted: vi.fn(), voteCast: vi.fn(), @@ -93,6 +95,14 @@ vi.mock('~/partials/entity-page/claim-voter-avatars', () => ({ }, })); +vi.mock('~/design-system/prefetch-link', () => ({ + PrefetchLink: ({ children, href, ...props }: { children: ReactNode; href: string }) => ( + + {children} + + ), +})); + beforeEach(() => { mocks.getCounts.mockReset(); mocks.getCounts.mockReturnValue({ positive: 2, negative: 1 }); @@ -109,6 +119,7 @@ beforeEach(() => { mocks.responderAvatarProps.length = 0; mocks.getProfiles.mockReset(); mocks.getProfiles.mockReturnValue([]); + mocks.personProfileOpened.mockReset(); }); afterEach(cleanup); @@ -287,10 +298,15 @@ function renderButtons(ready: boolean, seedCaches = false, responseKind: 'stance * not part of the boundary's `ready`. */ describe('RespondersPopoverContent under a batch', () => { - const renderPopover = (queryClient: QueryClient) => + const renderPopover = (queryClient: QueryClient, responseKind: 'stance' | 'curation' = 'stance') => render( - + , { wrapper: ({ children }: { children: ReactNode }) => ( @@ -343,4 +359,33 @@ describe('RespondersPopoverContent under a batch', () => { await waitFor(() => expect(view.getByText('Dovile')).toBeInTheDocument()); expect(view.container.querySelector('.overflow-y-auto')?.className).toContain('overscroll-contain'); }); + + it.each([ + ['stance', 'claim_vote_list'], + ['curation', 'entity_vote_list'], + ] as const)('attributes %s responder navigation to the right surface', async (responseKind, interactionSurface) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + queryClient.setQueryData(entityRespondersQueryKey('claim-1', 'space-1', 0, responseKind), [ + { userId: 'profile-9', direction: 'positive' }, + ]); + mocks.getProfiles.mockReturnValue([ + { + id: 'person-9', + spaceId: 'profile-9', + address: '0x1234567890abcdef', + avatarUrl: null, + coverUrl: null, + name: 'Dovile', + profileLink: '/space/profile-9', + }, + ]); + + const view = renderPopover(queryClient, responseKind); + const profileLink = await view.findByRole('link', { name: 'Dovile' }); + fireEvent.click(profileLink); + + expect(mocks.personProfileOpened).toHaveBeenCalledWith('profile-9', 'person-9', { + interaction_surface: interactionSurface, + }); + }); }); diff --git a/apps/web/partials/entity-page/entity-vote-buttons.tsx b/apps/web/partials/entity-page/entity-vote-buttons.tsx index d79e906ac9..0eee8ee7e6 100644 --- a/apps/web/partials/entity-page/entity-vote-buttons.tsx +++ b/apps/web/partials/entity-page/entity-vote-buttons.tsx @@ -10,7 +10,7 @@ import cx from 'classnames'; import { Effect } from 'effect'; import { useStore } from 'jotai'; -import { trackPrivyAuth } from '~/core/analytics'; +import { personProfileOpened, trackPrivyAuth } from '~/core/analytics'; import { useEntityResponse } from '~/core/hooks/use-entity-vote'; import { usePrepareOnboarding } from '~/core/hooks/use-prepare-onboarding'; import { useSmartAccount } from '~/core/hooks/use-smart-account'; @@ -498,6 +498,7 @@ export function RespondersPopoverContent({ responseKind: ResponseKind; }) { const copy = ENTITY_RESPONSE_COPY[responseKind]; + const interactionSurface = responseKind === 'curation' ? 'entity_vote_list' : 'claim_vote_list'; const respondersQueryKey = entityRespondersQueryKey(entityId, spaceId, objectType, responseKind); // These two ask for themselves, batch or no batch. @@ -560,27 +561,49 @@ export function RespondersPopoverContent({ heads its sections "Agreements" and "Disagreements" makes the reader translate on arrival — and "Verifications"/"Disputes" reads stranger still beside a button marked Verify. */} {positiveResponders.length > 0 && ( - + )} {negativeResponders.length > 0 && ( - + )} ); } -function ResponderSection({ label, responders }: { label: string; responders: ResponderWithProfile[] }) { +function ResponderSection({ + label, + responders, + interactionSurface, +}: { + label: string; + responders: ResponderWithProfile[]; + interactionSurface: 'claim_vote_list' | 'entity_vote_list'; +}) { return (
{label}
{responders.map(v => ( - + ))}
); } -function VoterRow({ profile }: { profile: Profile }) { +function VoterRow({ + profile, + interactionSurface, +}: { + profile: Profile; + interactionSurface: 'claim_vote_list' | 'entity_vote_list'; +}) { const content = (
@@ -591,7 +614,16 @@ function VoterRow({ profile }: { profile: Profile }) { ); if (profile.profileLink) { - return {content}; + return ( + + personProfileOpened(profile.spaceId, profile.id, { interaction_surface: interactionSurface }) + } + > + {content} + + ); } return content; diff --git a/apps/web/partials/explore/claim-explore-feed-card.tsx b/apps/web/partials/explore/claim-explore-feed-card.tsx index 270f7539df..83f6245bf7 100644 --- a/apps/web/partials/explore/claim-explore-feed-card.tsx +++ b/apps/web/partials/explore/claim-explore-feed-card.tsx @@ -319,6 +319,7 @@ export function ClaimExploreFeedCard({ diff --git a/apps/web/partials/explore/debate-explore-feed-card.test.tsx b/apps/web/partials/explore/debate-explore-feed-card.test.tsx index 95d3525725..9fc708dd8a 100644 --- a/apps/web/partials/explore/debate-explore-feed-card.test.tsx +++ b/apps/web/partials/explore/debate-explore-feed-card.test.tsx @@ -13,7 +13,7 @@ import type { ExploreFeedItem } from '~/core/explore/fetch-explore-feed'; import { NavUtils } from '~/core/utils/utils'; import { DebateExploreFeedCard } from './debate-explore-feed-card'; -import { entitySidePanelAtom } from '~/atoms'; +import { entityCommentsPanelAtom, entitySidePanelAtom } from '~/atoms'; // Reached through the claims panel, which now carries the shared response controls. The module's // top-level `atomWithStorage` runs on import, and under Node's own webstorage — which shadows @@ -247,6 +247,15 @@ function PanelProbe() { return
{target ? `${target.entityId} in ${target.spaceId}` : 'closed'}
; } +function CommentsPanelProbe() { + const target = useAtomValue(entityCommentsPanelAtom); + return ( +
+ {target ? `${target.entityId} in ${target.spaceId} (${target.targetEntityType})` : 'closed'} +
+ ); +} + // The card is rendered inside the app's query provider — its comment count reads the comments cache — // so the harness has to provide one too, or the double is laxer than the real tree. let client: QueryClient; @@ -265,6 +274,7 @@ function CardHarness({ {allowedId === undefined ? card : {card}} + ); @@ -558,10 +568,20 @@ describe('DebateExploreFeedCard', () => { const comments = screen.getByRole('button', { name: /^Comments/ }); expect(comments.textContent).toBe('3'); expect(screen.getByRole('button', { name: /^Claims/ }).textContent).toBe('18'); + expect(comments).toHaveAttribute('data-geo-analytics-label', 'Debate comments'); + expect(screen.getByRole('button', { name: /^Claims/ })).toHaveAttribute( + 'data-geo-analytics-label', + 'Debate claims' + ); // Marked as an opener so pressing it while the global comments panel is open switches the // panel to this debate instead of reading as an outside click that dismisses it. expect(comments.hasAttribute('data-entity-comments-opener')).toBe(true); + + fireEvent.click(comments); + expect(screen.getByTestId('comments-panel-target')).toHaveTextContent( + `${item.entityId} in ${item.spaceId} (debate)` + ); }); it('keeps votes and comments while the debate is still loading', () => { diff --git a/apps/web/partials/explore/debate-explore-feed-card.tsx b/apps/web/partials/explore/debate-explore-feed-card.tsx index 123510a2fc..9c3d853154 100644 --- a/apps/web/partials/explore/debate-explore-feed-card.tsx +++ b/apps/web/partials/explore/debate-explore-feed-card.tsx @@ -311,7 +311,7 @@ export function DebateExploreFeedCard({ spaceId: item.spaceId, commentCount, commentsPanelOpen: commentsTarget?.entityId === item.entityId, - onComment: () => openComments(item.entityId, item.spaceId), + onComment: () => openComments(item.entityId, item.spaceId, 'debate'), claimsCount: claims.totalCount, // See the prop's own note: an explore card can be a data block row listing a debate from // another space, and only the lookup finds that space's votes. diff --git a/apps/web/partials/explore/email-capture-account-step.tsx b/apps/web/partials/explore/email-capture-account-step.tsx index 2a78060fe5..63b2086df3 100644 --- a/apps/web/partials/explore/email-capture-account-step.tsx +++ b/apps/web/partials/explore/email-capture-account-step.tsx @@ -41,18 +41,33 @@ export function AccountStep({ email, onGiveUp }: { email: string; onGiveUp: () = loginWithCode, state: otpState, } = useLoginWithEmail({ - onComplete: args => trackPrivyAuth(args, { auth_flow: 'manual_login', link_source: 'explore_email_capture' }), + onComplete: args => + trackPrivyAuth(args, { + auth_flow: 'manual_login', + link_source: 'explore_email_capture', + form_type: 'account', + signup_surface: 'explore_email_capture', + }), }); - // Here for the same reason as the hook above, and it is the one that matters more: this registers - // a second `useLogin` beside the navbar's own, and the navbar's is the login button people - // actually press. Mounted in the parent it would do that on every Explore visit. - const openPrivyModal = usePrivySignIn(); - const [code, setCode] = React.useState(''); - // Held in a ref so the effect below does not re-run and re-send when the callback identity // changes, which would mail a second code on an unrelated re-render. const giveUpRef = React.useRef(onGiveUp); giveUpRef.current = onGiveUp; + // Here for the same reason as the hook above, and it is the one that matters more: this registers + // a second `useLogin` beside the navbar's own, and the navbar's is the login button people + // actually press. Mounted in the parent it would do that on every Explore visit. If the shortcut + // cannot send a code, the parent hides this card behind Privy's modal without unmounting it, so + // this hook still owns the completion and can attribute it to the email-capture surface. + const openPrivyModal = usePrivySignIn(undefined, { + analytics: { + link_source: 'explore_email_capture', + form_type: 'account', + signup_surface: 'explore_email_capture', + }, + onError: () => giveUpRef.current(), + }); + const [code, setCode] = React.useState(''); + const openPrivyModalRef = React.useRef(openPrivyModal); openPrivyModalRef.current = openPrivyModal; // Behind a ref because a hook's returned callbacks are new objects on every render. Naming @@ -88,14 +103,9 @@ export function AccountStep({ email, onGiveUp }: { email: string; onGiveUp: () = if (!mountedRef.current) return; // Captcha, a Privy outage, an address it will not take. Hand them the dialog that does work // rather than a dead end. - // Closing this card unmounts the `usePrivySignIn` instance that registered the modal's - // completion callback, so this flow's own `link_source` attribution is lost for the fallback. - // The sign-in itself is still recorded: the navbar's `GeoConnectButton` is mounted for every - // logged-out reader (`navbar-actions.tsx` renders it whenever there is no address) and its - // `onComplete` tracks unconditionally. Keeping this mounted behind the modal to reclaim one - // attribution field would mean a small state machine in auth code, watching the modal open - // and close again, for a branch that only runs when `sendCode` has already failed. - giveUpRef.current(); + // Keep this step mounted while the parent hides it behind the modal. Its own sign-in hook + // then observes completion and preserves this surface's attribution. Dismissal comes back + // through the hook's `onError` and closes the attempt. openPrivyModalRef.current(); } finally { if (mountedRef.current) setSending(false); @@ -146,7 +156,13 @@ export function AccountStep({ email, onGiveUp }: { email: string; onGiveUp: () = }, [busy]); return ( -
+ {/* The card's own subtext style, shared from the popup so the two states are one design rather than two that drifted. */}

diff --git a/apps/web/partials/explore/email-capture-popup.test.tsx b/apps/web/partials/explore/email-capture-popup.test.tsx index 7c2ae98c67..ea1bb04578 100644 --- a/apps/web/partials/explore/email-capture-popup.test.tsx +++ b/apps/web/partials/explore/email-capture-popup.test.tsx @@ -26,7 +26,15 @@ const mocks = vi.hoisted(() => ({ prepareOnboarding: vi.fn(), useGeoLoginWithEmail: vi.fn(), usePrivySignIn: vi.fn(), + usePrivySignInOptions: undefined as undefined | { analytics?: Record; onError?: () => void }, useLoginWithEmailArgs: undefined as unknown, + signupCompleted: vi.fn(), + trackPrivyAuth: vi.fn(), +})); + +vi.mock('~/core/analytics', () => ({ + signupCompleted: mocks.signupCompleted, + trackPrivyAuth: mocks.trackPrivyAuth, })); vi.mock('@geogenesis/auth', () => ({ @@ -45,8 +53,9 @@ vi.mock('@geogenesis/auth', () => ({ })); vi.mock('~/core/hooks/use-privy-sign-in', () => ({ - usePrivySignIn: () => { + usePrivySignIn: (_onComplete?: () => void, options?: { analytics?: Record; onError?: () => void }) => { mocks.usePrivySignIn(); + mocks.usePrivySignInOptions = options; return mocks.openPrivyModal; }, })); @@ -89,6 +98,9 @@ beforeEach(() => { mocks.prepareOnboarding.mockReset(); mocks.useGeoLoginWithEmail.mockReset(); mocks.usePrivySignIn.mockReset(); + mocks.usePrivySignInOptions = undefined; + mocks.signupCompleted.mockReset(); + mocks.trackPrivyAuth.mockReset(); mocks.useLoginWithEmailArgs = undefined; mocks.otpState = { status: 'initial' }; mocks.fetch.mockReset(); @@ -121,6 +133,8 @@ describe('ExploreEmailCapturePopup', () => { scrollPastTrigger(); expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-label', 'Explore newsletter signup'); + expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-type', 'newsletter'); + expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-intent', 'signup'); fireEvent.change(screen.getByRole('textbox'), { target: { value: 'reader@example.com' } }); await act(async () => { @@ -129,6 +143,17 @@ describe('ExploreEmailCapturePopup', () => { fireEvent.click(screen.getByRole('button', { name: 'Create account' })); expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-label', 'Explore account verification'); + expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-type', 'account'); + expect(popup()?.querySelector('form')).toHaveAttribute('data-geo-analytics-intent', 'signup'); + }); + + it('records a completed newsletter signup only after the subscription succeeds', async () => { + await subscribeSuccessfully(); + + expect(mocks.signupCompleted).toHaveBeenCalledWith('newsletter', { + signup_surface: 'explore_email_capture', + newsletter_source: 'explore', + }); }); it('stays away until the reader has scrolled', () => { @@ -856,6 +881,16 @@ describe('ExploreEmailCapturePopup', () => { const args = mocks.useLoginWithEmailArgs as { onComplete?: (a: unknown) => void } | undefined; expect(typeof args?.onComplete).toBe('function'); + + const completion = { user: { id: 'did:privy:new-user' }, isNewUser: true }; + args?.onComplete?.(completion); + + expect(mocks.trackPrivyAuth).toHaveBeenCalledWith(completion, { + auth_flow: 'manual_login', + link_source: 'explore_email_capture', + form_type: 'account', + signup_surface: 'explore_email_capture', + }); }); // Both resend controls used to stay live while a verification was in flight, so pressing one @@ -912,13 +947,30 @@ describe('ExploreEmailCapturePopup', () => { // email is a convenience, and it must not become the reason nobody can sign up at all. it('falls back to the normal sign-in dialog when the shortcut cannot start', async () => { mocks.sendCode.mockRejectedValue(new Error('captcha required')); - await subscribeSuccessfully(); + const view = await subscribeSuccessfully(); await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Create account' })); }); expect(mocks.openPrivyModal).toHaveBeenCalledTimes(1); + expect(mocks.usePrivySignInOptions?.analytics).toEqual({ + link_source: 'explore_email_capture', + form_type: 'account', + signup_surface: 'explore_email_capture', + }); + // The modal replaces the popup visually, but this hook must remain mounted until Privy + // completes so its completion handler can attribute the signup to this surface. + expect(popup()).toBeInTheDocument(); + + mocks.isModalOpen = true; + // The popup hides behind Privy without unmounting its account step. + view.rerender(); + expect(popup()).toBeNull(); + + act(() => mocks.usePrivySignInOptions?.onError?.()); + mocks.isModalOpen = false; + view.rerender(); expect(popup()).toBeNull(); }); }); diff --git a/apps/web/partials/explore/email-capture-popup.tsx b/apps/web/partials/explore/email-capture-popup.tsx index dad6677e4a..956f31bd9d 100644 --- a/apps/web/partials/explore/email-capture-popup.tsx +++ b/apps/web/partials/explore/email-capture-popup.tsx @@ -7,6 +7,7 @@ import * as React from 'react'; import cx from 'classnames'; import { useAtomValue } from 'jotai'; +import { signupCompleted } from '~/core/analytics'; import { useDebatesHub } from '~/core/debates/matchmaking/use-debates-hub'; import { useAnyModalOpen } from '~/core/hooks/use-any-modal-open'; import { useDismissedNotice } from '~/core/hooks/use-dismissed-notice'; @@ -189,6 +190,14 @@ function EmailCapturePopup() { }); const body = (await response.json()) as { result?: NewsletterSubscribeResult }; if (body.result === 'subscribed') { + try { + signupCompleted('newsletter', { + signup_surface: 'explore_email_capture', + newsletter_source: 'explore', + }); + } catch { + /* Analytics must never turn a successful subscription into a failed one. */ + } setStatus('done'); setSubscribedEmail(email.trim()); // Recorded, not closed. Having joined is the strongest reason not to ask again next @@ -373,7 +382,13 @@ function EmailCapturePopup() { // different wording in every browser, unstyleable, and gone the moment they look away. // `inputMode` and `autoComplete` keep the phone keyboard and the autofill that // `type="email"` was there for. - +

Geo network launching soon!

Get updates on features, points, and path to mainnet.

diff --git a/apps/web/partials/explore/explore-welcome-banner.tsx b/apps/web/partials/explore/explore-welcome-banner.tsx index 68cb165b8b..d6e6093db7 100644 --- a/apps/web/partials/explore/explore-welcome-banner.tsx +++ b/apps/web/partials/explore/explore-welcome-banner.tsx @@ -77,6 +77,8 @@ function WelcomeBanner() { opener makes. */}