= {};
return (
@@ -227,7 +343,14 @@ export function TagSelection({
)}
{!isPending &&
tags?.map((tag) => {
+ if (!tag.name) {
+ return null;
+ }
+ if (removedTags.has(tag.name)) {
+ return null;
+ }
const isSelected = selectedTags.has(tag.name);
+ const isExiting = exitingTags.has(tag.name);
renderedTags[tag.name] = true;
return (
@@ -236,7 +359,9 @@ export function TagSelection({
tag={tag}
onClick={handleClickTag}
isSelected={isSelected}
- isHighlighted={!searchQuery && !!recommendedTags?.has(tag.name)}
+ isHighlighted={!searchQuery && recommendedNames.has(tag.name)}
+ isExiting={isExiting}
+ onExited={handleTagExited}
data-funnel-track={FunnelTargetId.FeedTag}
/>
);
diff --git a/packages/shared/src/graphql/feedSettings.ts b/packages/shared/src/graphql/feedSettings.ts
index 9f69231b199..de2f622e696 100644
--- a/packages/shared/src/graphql/feedSettings.ts
+++ b/packages/shared/src/graphql/feedSettings.ts
@@ -57,6 +57,13 @@ export interface TagCategory {
emoji: string;
}
+export interface GQLPersona {
+ id: string;
+ title: string;
+ emoji: string;
+ tags: string[];
+}
+
export interface AllTagCategoriesData {
feedSettings?: FeedSettings;
loggedIn?: boolean;
@@ -190,3 +197,14 @@ export const GET_RECOMMENDED_TAGS_QUERY = gql`
}
}
`;
+
+export const GET_ONBOARDING_PERSONAS_QUERY = gql`
+ query OnboardingPersonas {
+ onboardingPersonas {
+ id
+ title
+ emoji
+ tags
+ }
+ }
+`;
diff --git a/packages/shared/src/hooks/useRecommendedTags.ts b/packages/shared/src/hooks/useRecommendedTags.ts
new file mode 100644
index 00000000000..f9ddfd62435
--- /dev/null
+++ b/packages/shared/src/hooks/useRecommendedTags.ts
@@ -0,0 +1,163 @@
+import type { MutableRefObject } from 'react';
+import type { QueryKey, UseMutationResult } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { gqlClient } from '../graphql/common';
+import type { Tag, TagsData } from '../graphql/feedSettings';
+import { GET_RECOMMENDED_TAGS_QUERY } from '../graphql/feedSettings';
+
+export interface RecommendTagsArgs {
+ /**
+ * One or more seed tag names. Each seed triggers an independent
+ * `recommendedTags` query in parallel; results are deduped and merged.
+ */
+ seeds: string[];
+ /**
+ * If set and present in the cache, recommendations are spliced after
+ * this tag. Used by manual single-tag clicks. Persona fan-outs leave
+ * it undefined and the recommendations are appended.
+ */
+ anchorTag?: string;
+ /**
+ * When true, the previous recommendation batch (minus anything now
+ * selected) is evicted from the screen with a fade-out. When false
+ * (the default), new recommendations are added on top of the
+ * existing ones — adding is immediate, eviction is deferred.
+ */
+ evictPrevious?: boolean;
+}
+
+export interface UseRecommendedTagsArgs {
+ onboardingTagsQueryKey: QueryKey;
+ excludedTags: string[];
+ /** Cap on the visible recommendations after dedupe. Default: 8. */
+ limit?: number;
+ recommendedRef: MutableRefObject>;
+ /**
+ * Names that are currently selected by the user. Tags in this set are
+ * never evicted, even if a new recommendation batch supersedes them.
+ */
+ selectedRef?: MutableRefObject>;
+ /**
+ * Called with the full accumulated recommendation set after each
+ * mutation — drives `recommendedNames` state for highlighting.
+ */
+ onRecommended?: (tagNames: string[]) => void;
+ /**
+ * Called with only the freshly-fetched recommendation names (the
+ * additions in this mutation), not the accumulated set. Drives the
+ * pop/spark animation so previously-shown tags don't re-pop.
+ */
+ onFreshRecommendations?: (tagNames: string[]) => void;
+ /**
+ * Called before the new batch is committed, with the names from the
+ * previous batch that should now play their fade-out animation.
+ */
+ onEvicting?: (tagNames: string[]) => void;
+}
+
+export type UseRecommendedTagsResult = UseMutationResult<
+ Set,
+ Error,
+ RecommendTagsArgs
+>;
+
+export function useRecommendedTags({
+ onboardingTagsQueryKey,
+ excludedTags,
+ limit = 8,
+ recommendedRef,
+ selectedRef,
+ onRecommended,
+ onFreshRecommendations,
+ onEvicting,
+}: UseRecommendedTagsArgs): UseRecommendedTagsResult {
+ const queryClient = useQueryClient();
+
+ return useMutation, Error, RecommendTagsArgs>({
+ mutationFn: async ({ seeds, anchorTag, evictPrevious = false }) => {
+ const uniqueSeeds = Array.from(new Set(seeds.filter(Boolean)));
+ if (!uniqueSeeds.length) {
+ return new Set();
+ }
+
+ const responses = await Promise.all(
+ uniqueSeeds.map((seed) =>
+ gqlClient.request<{ recommendedTags: TagsData }>(
+ GET_RECOMMENDED_TAGS_QUERY,
+ { tags: [seed], excludedTags },
+ ),
+ ),
+ );
+
+ const dedupedNames = new Set();
+ const dedupedTags: Tag[] = [];
+ const excludedSet = new Set(excludedTags);
+
+ responses.forEach((response) => {
+ response.recommendedTags.tags.forEach((tag) => {
+ if (
+ tag.name &&
+ !dedupedNames.has(tag.name) &&
+ !excludedSet.has(tag.name) &&
+ !recommendedRef.current.has(tag.name) &&
+ dedupedTags.length < limit
+ ) {
+ dedupedNames.add(tag.name);
+ dedupedTags.push(tag);
+ }
+ });
+ });
+
+ if (evictPrevious) {
+ const previousRecommendations = Array.from(recommendedRef.current);
+ const selectedNow = selectedRef?.current ?? new Set();
+ const evicting = previousRecommendations.filter(
+ (name) => !dedupedNames.has(name) && !selectedNow.has(name),
+ );
+ if (evicting.length) {
+ onEvicting?.(evicting);
+ }
+ }
+
+ queryClient.setQueryData(onboardingTagsQueryKey, (current) => {
+ if (!current) {
+ return current;
+ }
+
+ const freshTags = dedupedTags.filter(
+ (tag) =>
+ tag.name &&
+ !current.tags.some((existing) => existing.name === tag.name),
+ );
+
+ if (!freshTags.length) {
+ return current;
+ }
+
+ const insertIndex = anchorTag
+ ? current.tags.findIndex((tag) => tag.name === anchorTag)
+ : -1;
+
+ if (insertIndex >= 0) {
+ return {
+ tags: [
+ ...current.tags.slice(0, insertIndex + 1),
+ ...freshTags,
+ ...current.tags.slice(insertIndex + 1),
+ ],
+ };
+ }
+
+ return { tags: [...current.tags, ...freshTags] };
+ });
+
+ if (evictPrevious) {
+ recommendedRef.current.clear();
+ }
+ dedupedNames.forEach((name) => recommendedRef.current.add(name));
+ onRecommended?.(Array.from(recommendedRef.current));
+ onFreshRecommendations?.(Array.from(dedupedNames));
+ return dedupedNames;
+ },
+ });
+}
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index c5d8199a1e1..a222622734c 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -158,6 +158,11 @@ export const sharedPostPreviewFeature = new Feature(
export const featureOnboardingV2 = new Feature('onboarding_v2', false);
+export const featureOnboardingPersonas = new Feature(
+ 'onboarding_personas',
+ false,
+);
+
export const featurePostSignupWidget = new Feature('post_signup_widget', false);
export const featureShortcutsExtensionPromo = new Feature(
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index 2a392afef63..5ffd703feed 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -53,6 +53,7 @@ export enum Origin {
Onboarding = 'onboarding',
ManageTag = 'manage_tag',
EditTag = 'edit_tag',
+ OnboardingPersona = 'onboarding persona',
// Collection
CollectionModal = 'collection modal',
Settings = 'settings',
@@ -425,6 +426,8 @@ export enum LogEvent {
QuestClaimable = 'quest claimable',
ClaimQuest = 'claim quest',
Dismiss = 'dismiss',
+ // Onboarding personas
+ SelectOnboardingPersona = 'select onboarding persona',
}
export enum TargetType {
diff --git a/packages/shared/src/styles/utilities.css b/packages/shared/src/styles/utilities.css
index 097806d4693..e4af5f4bf48 100644
--- a/packages/shared/src/styles/utilities.css
+++ b/packages/shared/src/styles/utilities.css
@@ -162,6 +162,15 @@
opacity: 0;
}
+.tag-spark {
+ position: absolute;
+ width: 4px;
+ height: 4px;
+ border-radius: 9999px;
+ background: var(--theme-accent-cabbage-default);
+ pointer-events: none;
+}
+
@keyframes leaderboard-medal-spark {
0% {
transform: translate(-50%, -50%) scale(1);
diff --git a/packages/shared/tailwind.config.ts b/packages/shared/tailwind.config.ts
index 89a82ff1fcb..87ca883f9fc 100644
--- a/packages/shared/tailwind.config.ts
+++ b/packages/shared/tailwind.config.ts
@@ -252,12 +252,54 @@ export default {
backgroundColor: 'transparent',
},
},
+ 'tag-pop': {
+ '0%': {
+ transform: 'scale(0.92)',
+ boxShadow: '0 0 0 0 transparent',
+ },
+ '40%': {
+ transform: 'scale(1.08)',
+ boxShadow:
+ '0 0 0 4px color-mix(in srgb, var(--theme-accent-cabbage-default) 35%, transparent)',
+ },
+ '100%': {
+ transform: 'scale(1)',
+ boxShadow: '0 0 0 0 transparent',
+ },
+ },
+ 'tag-spark': {
+ '0%': {
+ transform: 'translate(0, 0) scale(0.4)',
+ opacity: '0',
+ },
+ '20%': {
+ opacity: '1',
+ },
+ '100%': {
+ transform:
+ 'translate(var(--spark-fx, 0px), var(--spark-fy, 0px)) scale(0)',
+ opacity: '0',
+ },
+ },
+ 'tag-fade-out': {
+ '0%': {
+ transform: 'scale(1)',
+ opacity: '1',
+ },
+ '100%': {
+ transform: 'scale(0.6)',
+ opacity: '0',
+ },
+ },
},
animation: {
'scale-down-pulse':
'scale-down-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'fade-slide-up': 'fade-slide-up 0.5s ease-out 1s both',
'highlight-fade': 'highlight-fade 2.5s ease-out forwards',
+ 'tag-pop': 'tag-pop 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both',
+ 'tag-spark': 'tag-spark 0.6s ease-out both',
+ 'tag-fade-out': 'tag-fade-out 0.25s ease-in forwards',
},
},
lineClamp: {