Appearance
Epic 33 — Social Graph (Follow, Search & Suggestions)
Today every user sees every published post (the
feedquery is a global recency stream —BE-FEED-003). This epic turns the platform into a real social network: a follow graph, a Following / For You feed split, a universal people + content search, and a "who to follow" suggestion engine that reuses the Qdrant embedding infra from Epic 6, and admin content moderation so admins can browse all user posts and unlist or remove non-compliant ones.Design decisions (locked):
- Follow graph lives on
User(user ↔ user), not on a single profile type — any role can follow any role. Only talents and producers author posts today (Post.authorId/Post.producerAuthorId), so following a pet-owner currently yields no feed content; the graph is intentionally future-proofed.- Feed becomes two tabs: Following (chronological posts from accounts you follow) and For You (ranked discovery — current global behavior + suggestions + system posts). New users are never stranded with an empty feed.
- Search is a new unified surface (people + posts) for all roles. The existing casting-oriented
searchTalents/TalentSearchFilters(Epic 6) stays separate — it is a hiring tool, not social search.- External social links are handle-only (Instagram / Facebook / TikTok / YouTube / Website): users store a handle per platform and we deep-link out (BE-SOCIAL-006 / FE-SOCIAL-005). We do not fetch or embed their off-platform content — the official APIs don't cover our mostly-personal-account user base (IG Basic Display shut down 2024-12-04, FB personal-profile posts are closed, TikTok Display API needs an app audit). OAuth content-embedding is parked as a possible Phase 2.
BE-SOCIAL-001 — Follow model
- [x] Done
Files:
- Edit:
castyou-backend/prisma/schema.prisma— addFollowmodel + back-relations onUser - Run:
pnpm db:migrate(Postgres schema change —prisma generatealone is not enough)
Schema:
prisma
model Follow {
id String @id @default(cuid())
followerId String
follower User @relation("Following", fields: [followerId], references: [id], onDelete: Cascade)
followingId String
following User @relation("Followers", fields: [followingId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([followerId, followingId]) // idempotent follow; one edge per pair
@@index([followingId, createdAt]) // "who follows me", newest first
@@index([followerId, createdAt]) // "who I follow", newest first
@@map("follows")
}Add to model User:
prisma
following Follow[] @relation("Following") // edges where this user is the follower
followers Follow[] @relation("Followers") // edges where this user is being followedNotes: A user cannot follow themselves — enforced in the service layer (BE-SOCIAL-002), not the DB. Counts are derived via _count (no denormalised counters in MVP; revisit if follower lists get large).
BE-SOCIAL-002 — Follow/unfollow mutations + relationship queries
- [x] Done
Files:
- Create:
castyou-backend/src/services/social/index.ts—follow(actorId, targetId),unfollow(actorId, targetId),getFollowers(userId, page, pageSize),getFollowing(userId, page, pageSize),getRelationship(actorId, targetId) - Edit:
castyou-backend/src/graphql/schema/index.ts - Create:
castyou-backend/src/graphql/resolvers/social.ts - Edit:
castyou-backend/src/graphql/resolvers/index.ts - Create:
castyou-backend/src/__tests__/resolvers/social.test.ts
GraphQL:
graphql
type FollowEdge {
user: PublicUser! # the other party (resolved per query)
followedAt: DateTime!
followsMe: Boolean! # does this user follow the viewer back?
followedByMe: Boolean! # does the viewer follow this user?
}
type FollowConnection { items: [FollowEdge!]! page: PageInfo! }
# A lightweight, role-agnostic public identity used across social surfaces.
type PublicUser {
id: ID!
displayName: String!
handle: String
avatarUrl: String
role: String! # TALENT | PRODUCER | AGENCY | PET_OWNER
headline: String # primaryCategory (talent) or company (producer)
followerCount: Int!
followedByMe: Boolean!
}
# Queries
followers(userId: ID!, page: Int = 1, pageSize: Int = 20): FollowConnection!
following(userId: ID!, page: Int = 1, pageSize: Int = 20): FollowConnection!
# Mutations
followUser(userId: ID!): PublicUser! # idempotent; returns updated target
unfollowUser(userId: ID!): PublicUser! # idempotentRules:
- Reject self-follow (
actorId === targetId) with a user-facing error. followUser/unfollowUserare idempotent — re-following an already-followed user is a no-op success (upsert on the unique pair).- Both lists are paginated (per pagination rule) —
page/pageSizeargs +pageobject. Never return an unbounded follower list. followUserfires aNEW_FOLLOWERnotification (BE-NOTIF-003) — but must be silent under admin impersonation (Epic 24): no notification row, no email/push when the actor is an impersonated session.PublicUser.role/headline/avatarUrlresolve from whichever profile the user owns (talent →displayName/primaryCategory/headlinePhoto; producer → company name/logo).
BE-SOCIAL-003 — Following feed tab
- [x] Done
Files:
- Edit:
castyou-backend/src/services/feed/index.ts— addfollowingFeed(viewerId, first, after) - Edit:
castyou-backend/src/graphql/resolvers/feed.ts - Edit:
castyou-backend/src/graphql/schema/index.ts - Edit:
castyou-backend/src/__tests__/resolvers/feed.test.ts
GraphQL: extend the existing feed with an explicit tab rather than overloading feed:
graphql
enum FeedTab { FOLLOWING FOR_YOU }
# Replaces the bare feed(first, after) call site; FOR_YOU preserves today's behavior.
feed(tab: FeedTab! = FOR_YOU, first: Int = 10, after: String): PostConnection!Following query logic:
- Resolve the set of
followingIds for the viewer. - Map those user ids → their
talentProfile.idandproducerProfile.id. - Return
Postwherestatus = PUBLISHEDAND (authorId IN talentIdsORproducerAuthorId IN producerIds), orderedcreatedAt DESC, cursor-paginated (after= last post id), reusing the existingPostConnectionshape. - System posts are NOT included in Following (they belong to For You / the official-posts surface) — Following is strictly accounts you chose to follow.
- Empty result → resolver returns an empty connection; the FE shows the bootstrap empty state (FE-SOCIAL-001).
BE-SOCIAL-004 — "For You" feed ranking
- [x] Done
Files:
- Edit:
castyou-backend/src/services/feed/index.ts— addforYouFeed(viewerId, first, after)(theFOR_YOUbranch of BE-SOCIAL-003's resolver) - Edit:
castyou-backend/src/__tests__/services/feed.test.ts
Description: FOR_YOU keeps the current global stream as its backbone but ranks for discovery. Candidate set = published posts from the last N days (excluding the viewer's own + posts from blocked/hidden authors) ∪ system posts. Blend score:
| Signal | Weight | Source |
|---|---|---|
| Recency | high | createdAt decay |
| Engagement | medium | likeCount + commentCount + shareCount (normalised) |
| Author affinity | medium | Qdrant similarity between viewer's engagement profile and the post author's embedding (reuse Epic 6 vectorStore / embeddings) |
| Already-followed penalty | low (negative) | de-prioritise authors already in Following — For You is for discovery |
Dev mode: falls back to engagement-weighted recency (no Qdrant), mirroring the Epic 6 matching fallback. Cursor pagination is score-then-id stable. System posts are interleaved at a fixed cadence (e.g. every 8th card) rather than ranked, so official content always surfaces.
BE-SEARCH-002 — Unified search (people + content)
- [x] Done
Files:
- Create:
castyou-backend/src/services/search/unified.ts—searchPeople(q, page, pageSize),searchPosts(q, page, pageSize) - Edit:
castyou-backend/src/graphql/schema/index.ts - Create:
castyou-backend/src/graphql/resolvers/search.ts(or extend existing) - Create:
castyou-backend/src/__tests__/resolvers/unifiedSearch.test.ts
GraphQL:
graphql
type PeopleSearchConnection { items: [PublicUser!]! page: PageInfo! }
type PostSearchConnection { items: [Post!]! page: PageInfo! }
# Queries — role-agnostic, available to every signed-in user
searchPeople(q: String!, page: Int = 1, pageSize: Int = 20): PeopleSearchConnection!
searchPosts(q: String!, page: Int = 1, pageSize: Int = 20): PostSearchConnection!Implementation — hybrid keyword + semantic (Qdrant) on both surfaces:
- People (hybrid): two retrieval passes merged into one ranked
PublicUserlist:- Keyword — case-insensitive
ILIKEacrossdisplayName/handle/stageName/primaryCategory(talent) and company name (producer), unioned across profile types. Owns exact-name / handle lookup (@johnsmith, "Maria Silva"). - Semantic — embed
qand query Qdrant over the talent profile embeddings already indexed in Epic 6 (displayName + primaryCategory + skills + experienceEntries + notableWorks + dreamGigs). Owns descriptive / intent queries ("moody cinematographers in Berlin"). Extendembeddings.indexProfileto also cover producers so company profiles are semantically searchable. - Merge & rank — fuse the two passes: a keyword exact-handle / exact-name hit always outranks a semantic-only hit; within the semantic tail, order by similarity score. De-dupe by
userId. Excludestatus != ACTIVE.
- Keyword — case-insensitive
- Posts (hybrid):
ILIKEoncaption(keyword) merged with semantic Qdrant match over post/caption embeddings (extend Epic 6's pipeline to index posts on create — shared with BE-SOCIAL-005). Same exact-then-semantic merge. - Dev mode (no Qdrant): both surfaces fall back to
ILIKE-only, mirroring the Epic 6 matching fallback — search still works, just without the semantic tail. - Both paginated (
page/pageSize+pageobject). Pagination is over the merged result set (stable order: keyword block, then semantic-by-score). Emptyq(after trim) returns an empty connection, not an error.
BE-SOCIAL-005 — "Who to follow" suggestion engine
- [x] Done
Files:
- Create:
castyou-backend/src/services/social/suggestions.ts—followSuggestions(viewerId, limit) - Edit:
castyou-backend/src/services/ai/embeddings.ts— also index posts (indexPost) so post + author affinity is queryable (shared with BE-SEARCH-002) - Edit:
castyou-backend/src/graphql/schema/index.ts - Edit:
castyou-backend/src/graphql/resolvers/social.ts - Create:
castyou-backend/src/__tests__/services/suggestions.test.ts
GraphQL:
graphql
type FollowSuggestion {
user: PublicUser!
reasons: [String!]! # human-readable, e.g. "Followed by 3 people you follow", "Posts about Cinematography"
score: Float! # 0–1
}
followSuggestions(limit: Int = 10): [FollowSuggestion!]!Ranking signals (blended, mirrors the Epic 6 matchReasons pattern):
- Social graph — friends-of-friends: accounts followed by people the viewer follows, weighted by overlap count.
- Content affinity — Qdrant similarity between the viewer's engagement/profile embedding and candidate author embeddings.
- Behavioral — authors whose posts the viewer has liked/commented/viewed (
PostLike/PostComment/viewCountsignals) but does not yet follow. - Cold-start / popularity — for viewers with an empty graph, fall back to trending accounts (most followers gained + most engagement in the last 7 days).
Always excluded: the viewer, already-followed users, blocked/suspended users. Each suggestion carries 1–3 reasons. Capped at limit (default 10, hard max 50) — log/comment the cap so it's clear suggestions are intentionally bounded, not exhaustive.
BE-NOTIF-003 — NEW_FOLLOWER notification type
- [x] Done
Files:
- Edit:
castyou-backend/src/services/notifications/index.ts— addNEW_FOLLOWERconstant + trigger fromsocialService.follow - Edit:
castyou-backend/src/graphql/schema/index.ts— extendNotificationTypeenum
| Type | Trigger | Target |
|---|---|---|
NEW_FOLLOWER | A user follows another user (BE-SOCIAL-002) | The followed user |
Rules: payload { followerId, followerDisplayName, followerAvatarUrl }; tapping navigates to the follower's profile. Suppressed entirely under admin impersonation (Epic 24) — assert in a regression test that an impersonated follow writes no notification row and sends no email/push. Follows are not messages, so the no-notification-for-messages rule does not apply here.
BE-ADMIN-004 — Admin content moderation API (browse / unlist / remove posts)
- [x] Done
Files:
- Edit:
castyou-backend/prisma/schema.prisma— addUNLISTEDtoPostStatus; add moderation fields toPost - Run:
pnpm db:migrate(Postgres schema change —prisma generatealone is not enough) - Edit:
castyou-backend/src/graphql/schema/index.ts— admin content queries/mutations; addPOSTto report target types - Edit:
castyou-backend/src/graphql/resolvers/admin.ts - Create:
castyou-backend/src/services/admin/contentModeration.ts - Create:
castyou-backend/src/__tests__/resolvers/adminContent.test.ts
Schema:
prisma
// extend existing enum — UNLISTED = hidden from all public surfaces but the post still exists
enum PostStatus { PUBLISHED DRAFT UNLISTED REMOVED }
// add to model Post:
moderatedById String? // admin User.id who last acted
moderatedAt DateTime?
moderationReason String? // e.g. "Nudity", "Spam", "Off-platform solicitation"
moderationAction String? // UNLIST | REMOVE | RESTOREStatus semantics:
| Status | In Following / For You feed? | In search? | Direct link? | Visible to author? | Visible to admin? |
|---|---|---|---|---|---|
PUBLISHED | ✅ | ✅ | ✅ | ✅ | ✅ |
UNLISTED | ❌ | ❌ | ✅ (not 404) | ✅ (with a "limited by moderation" banner) | ✅ |
REMOVED | ❌ | ❌ | ❌ (404) | ❌ | ✅ (in admin only) |
Feeds (BE-SOCIAL-003/004) and search (BE-SEARCH-002) already filter to
status = PUBLISHED, soUNLISTED/REMOVEDdrop out automatically — no extra coupling. Add a regression test asserting an unlisted/removed post is absent from both feeds and both search surfaces.
GraphQL:
graphql
input AdminPostFilter {
status: PostStatus # default: all
authorId: ID # filter to one user's posts
reportedOnly: Boolean # only posts with an open Report
q: String # caption contains
}
type AdminPost {
post: Post!
author: PublicUser!
reportCount: Int! # open reports against this post
moderatedBy: PublicUser
moderatedAt: DateTime
moderationReason: String
}
type AdminPostPage { items: [AdminPost!]! page: PageInfo! }
# Queries (admin only — adminGuard / isAdmin)
adminPosts(filter: AdminPostFilter, page: Int = 1, pageSize: Int = 20): AdminPostPage!
adminPost(id: ID!): AdminPost!
# Mutations (admin only)
unlistPost(id: ID!, reason: String!): AdminPost! # PUBLISHED → UNLISTED
removePost(id: ID!, reason: String!): AdminPost! # any → REMOVED
restorePost(id: ID!): AdminPost! # UNLISTED/REMOVED → PUBLISHEDRules:
- All queries/mutations gated by the existing admin guard (
isAdmin) — same pattern as BE-ADMIN-001. - Every moderation action writes the
moderated*fields and aUserActivityLogentry (action: "POST_MODERATION",detail: "<UNLIST|REMOVE|RESTORE> post <id>: <reason>") against the post author's userId, so it shows in their admin activity timeline. - Extend
reportContent(BE-ADMIN-002)targetTypeto acceptPOST; resolving a report can triggerunlistPost/removePost.AdminPostFilter.reportedOnlysurfaces the queue. adminPostsis paginated (page/pageSize+pageobject) — never an unbounded list (pagination rule).- Moderation is silent to the author by default (no notification in MVP); revisit a "your post was removed" notice later.
FE-ADMIN-003 — Admin content moderation UI (Content tab)
- [x] Done
Files:
- Edit:
castyou-frontend/apps/app/src/pages/admin/AdminPage.tsx— add a Content tab (alongside Users / Jobs / Reports) - Create:
castyou-frontend/apps/app/src/pages/admin/AdminContentPage.tsx - Create:
castyou-frontend/apps/app/src/components/admin/PostModerationModal.tsx - Create:
castyou-frontend/apps/app/src/hooks/useAdminContent.ts - Create:
castyou-frontend/apps/app/src/lib/queries/adminContent.ts—ADMIN_POSTS_QUERY,UNLIST_POST,REMOVE_POST,RESTORE_POST - Edit:
castyou-frontend/apps/app/src/pages/admin/AdminUserDetailPage.tsx— "Posts" section linking to this user's content (filterauthorId)
Description: Admin-only Content tab: a paginated table of posts — thumbnail, author (avatar + handle), caption excerpt, status badge (Published / Unlisted / Removed), like/comment counts, report badge, created date, and a row action menu (Unlist / Remove / Restore / View). Filters: status, reported-only, author, caption search. Clicking a row opens a preview with the full media + PostModerationModal (action picker + required reason field). Optimistic status update on action. Pagination controls below the table (pagination rule). All components from @castyou/design-system (design-system rule). Reported posts also surface in the existing Reports tab via the POST target type.
FE-SOCIAL-001 — Feed two-tab (Following / For You)
- [x] Done
Files:
- Edit:
castyou-frontend/apps/app/src/pages/feed/FeedPage.tsx— add a tab switcher - Edit:
castyou-frontend/apps/app/src/hooks/useFeed.ts— passtab - Edit:
castyou-frontend/packages/design-system/src/components/ui/Tabs.tsx— DSTabs(add if missing) - Edit:
castyou-frontend/packages/design-system/src/index.ts
Description: Top-of-feed Tabs: For You (default) and Following. Each tab drives feed(tab:) with its own infinite-scroll cursor (existing IntersectionObserver pattern). Following empty state (EmptyState from DS): "You're not following anyone yet" + a "Find people to follow" CTA → the suggestions screen (FE-SOCIAL-004). All components from @castyou/design-system (design-system rule).
FE-SOCIAL-002 — Follow button (DS component)
- [x] Done
Files:
- Edit:
castyou-frontend/packages/design-system/src/components/ui/FollowButton.tsx— new DS component (Follow / Following states, optimistic) - Edit:
castyou-frontend/packages/design-system/src/index.ts - Create:
castyou-frontend/apps/app/src/hooks/useFollow.ts—followUser/unfollowUsermutations with optimistic cache update + rollback - Edit:
castyou-frontend/apps/app/src/components/feed/PostCard.tsx— add Follow button to post header - Edit: talent/producer profile headers — add Follow button
Description: FollowButton shows "Follow" (primary) → "Following" (secondary; hover/long-press reveals "Unfollow"). Optimistic toggle, rolls back on error. Hidden on the viewer's own profile/posts.
FE-SOCIAL-003 — Follower / following counts + lists screen
- [x] Done
Files:
- Create:
castyou-frontend/apps/app/src/pages/social/FollowListPage.tsx— tabbed Followers / Following list - Create:
castyou-frontend/apps/app/src/lib/queries/social.ts—FOLLOWERS_QUERY,FOLLOWING_QUERY - Edit: profile header components — show
followerCount/followingCount, tappable →FollowListPage - Edit:
castyou-frontend/apps/app/src/App.tsx— route/u/:userId/followers,/u/:userId/following
Description: Profile headers show follower/following counts. Tapping opens a paginated list (rows = avatar + name + handle + FollowButton). Paginated with controls below the list (pagination rule). All DS components.
FE-SEARCH-001 — Universal search screen (People / Posts)
- [x] Done
Files:
- Create:
castyou-frontend/apps/app/src/pages/search/SearchPage.tsx - Create:
castyou-frontend/apps/app/src/lib/queries/search.ts—SEARCH_PEOPLE_QUERY,SEARCH_POSTS_QUERY - Create:
castyou-frontend/apps/app/src/hooks/useSearch.ts— debounced query - Edit:
castyou-frontend/apps/app/src/components/AppShell.tsx— search entry (nav bar / icon) - Edit:
castyou-frontend/apps/app/src/App.tsx—/searchroute
Description: A search bar with People and Posts result tabs. Debounced (~300 ms) input → searchPeople / searchPosts. People rows = PublicUser + FollowButton; Posts rows = compact PostCard. Empty query → recent searches / suggestions placeholder; no results → EmptyState. Paginated results. All DS components.
FE-SOCIAL-004 — "Suggested for you" rail + discovery screen
- [x] Done
Files:
- Create:
castyou-frontend/apps/app/src/pages/social/SuggestionsPage.tsx— full "Who to follow" screen - Create:
castyou-frontend/apps/app/src/components/social/SuggestionRail.tsx— horizontal rail - Create:
castyou-frontend/apps/app/src/lib/queries/social.ts(extend) —FOLLOW_SUGGESTIONS_QUERY - Edit:
castyou-frontend/packages/design-system/src/components/ui/SuggestionCard.tsx— DS card (avatar, name, reason chip, Follow button) - Edit:
castyou-frontend/packages/design-system/src/index.ts
Description: SuggestionRail (horizontal SuggestionCards) embedded in the For You feed (e.g. after the first few posts) and on the Following empty state. Each card shows the reasons[0] as a chip ("Followed by 3 people you follow"). Dedicated SuggestionsPage lists the full ranked set. Following from a card removes it from the rail (optimistic). All DS components.
BE-SOCIAL-006 — External social profile handles (Instagram / Facebook / TikTok)
- [x] Done
Scope (handle-only, locked 2026-06-09): users link their off-platform presence by storing a handle/username per platform and deep-linking out — CasTyou does not fetch or embed their latest posts.
Feasibility note — why handle-only (decision rationale, do not reopen without re-checking the APIs): a full "log in to show your latest content" flow was evaluated and rejected for MVP because the official APIs don't cover our (mostly personal-account) user base:
- Instagram — the Basic Display API that exposed any personal account's feed was permanently shut down 2024-12-04. The only remaining path is the Instagram Graph API, which requires a Business/Creator account linked to a Facebook Page — personal accounts cannot connect at all.
- Facebook — Meta's API is Page-centric; fetching a personal profile's posts is effectively closed (locked behind App Review + Business Verification and returns nothing useful for individuals).
- TikTok — the Display API (
/v2/user/info/,/v2/video/list/) can fetch a creator's recent videos via OAuth (scopesuser.info.basic,video.list), but requires passing TikTok's app audit (privacy policy + demo video, ~1–2 weeks) and a server-side token-refresh job.OAuth content-embedding (TikTok video embed + Instagram Business/Creator embed; Facebook always skipped) is parked as a possible Phase 2 enrichment, not part of this ticket.
Files:
- Reuse: the existing
socialLinks Json?field already onTalentProfile({ website, linkedin, instagram }) andProducerProfile({ instagram, tiktok, website }) — normalise both to a shared shape (see below) rather than adding new columns. No migration needed if we keepJson. - Create:
castyou-backend/src/services/social/handles.ts—normalizeHandle(platform, raw),socialProfileUrl(platform, handle),validateHandles(input) - Edit:
castyou-backend/src/graphql/schema/index.ts—SocialHandlestype +SocialHandlesInput, expose on the public profile - Edit: the talent/producer profile resolvers + the
updateProfilemutation to acceptsocialHandles - Create:
castyou-backend/src/__tests__/services/socialHandles.test.ts
GraphQL:
graphql
type SocialHandle {
platform: SocialPlatform!
handle: String! # normalised, no leading "@", no URL
url: String! # canonical profile URL, resolver-built
}
enum SocialPlatform { INSTAGRAM FACEBOOK TIKTOK YOUTUBE WEBSITE LINKEDIN }
input SocialHandleInput { platform: SocialPlatform! handle: String! }
# exposed on PublicUser / talent + producer public profiles
socialHandles: [SocialHandle!]!
# folded into the existing profile update
input UpdateProfileInput { ... socialHandles: [SocialHandleInput!] }Rules:
- Store only the handle, never a full URL.
normalizeHandlestrips a leading@, strips anyinstagram.com/…/tiktok.com/@…/facebook.com/…URL the user pastes down to the username, trims whitespace, lowercases where the platform is case-insensitive. urlis always built server-side from(platform, handle)viasocialProfileUrl— never trust a client-supplied URL (avoids open-redirect / spoofed links). TikTok →https://www.tiktok.com/@<handle>, Instagram →https://instagram.com/<handle>, Facebook →https://facebook.com/<handle>, etc.- Validate each handle against the platform's allowed charset/length; reject invalid with a per-field user-facing error. Empty/removed handle = delete that platform entry.
- No external network calls — we do not verify the account exists or fetch any content. (If Phase 2 OAuth embedding ever lands, that is where verification/fetching would live.)
WEBSITEallows a full URL (validatedhttps?://, rendered as-is).
FE-SOCIAL-005 — Social handles editor + profile links
- [x] Done
Files:
- Edit:
castyou-frontend/apps/app/src/pages/profile/…profile-edit form — add a "Social profiles" section - Edit:
castyou-frontend/packages/design-system/src/components/…—SocialHandleInput(platform icon +@prefix field) and aSocialLinksdisplay row (icon buttons), both in the DS - Edit:
castyou-frontend/packages/design-system/src/index.ts - Edit:
castyou-frontend/apps/app/src/lib/queries/social.ts(or the profile query/mutation) — includesocialHandles - Public profile view ([BE-PROFILE public view, Epic 5]) — render the
SocialLinksicon row in the existing "social links" slot
Description: In profile-edit, a "Social profiles" section with one input per platform (Instagram, Facebook, TikTok, YouTube, Website) showing a fixed @/icon prefix so users type only the handle; pasting a full URL is accepted and normalised on save. On the public profile, render handles as a row of tappable platform-icon buttons that open the canonical url (server-built) in a new tab. All components from @castyou/design-system. No embedded feeds — links out only.