diff --git a/app/(app)/messages/actions.ts b/app/(app)/messages/actions.ts index 07ec3013..35f6958a 100644 --- a/app/(app)/messages/actions.ts +++ b/app/(app)/messages/actions.ts @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; import { TEXT_LIMITS } from "@/lib/utils/validation"; +import { getPostHogServerClient } from "@/lib/posthog-server"; export async function startDmWithUsername(username: string) { const supabase = await createClient(); @@ -37,10 +38,10 @@ export type MessageUserResult = { is_pro: boolean; }; -// Candidates for the "New group" picker: accounts the viewer follows -// (accepted), minus anyone blocked either direction. Followed-only is a UI -// choice, not an RLS/RPC requirement -- create_group_conversation itself only -// hard-rejects a blocked creator<->member pair (see plan 025 NOTES). +// Candidates for the "New group" picker: accounts that follow the viewer +// (accepted), minus anyone blocked either direction. Mirrors the RPC rule: +// create_group_conversation and add_group_member only accept members who +// follow the caller. export async function listFollowedForGroup(): Promise { const supabase = await createClient(); const { @@ -52,14 +53,14 @@ export async function listFollowedForGroup(): Promise { supabase.rpc("get_blocked_ids"), supabase .from("follows") - .select("following:profiles!follows_following_id_fkey(id, username, display_name, avatar_url, is_pro)") - .eq("follower_id", user.id) + .select("follower:profiles!follows_follower_id_fkey(id, username, display_name, avatar_url, is_pro)") + .eq("following_id", user.id) .eq("status", "accepted"), ]); const blockedSet = new Set((blocked ?? []) as string[]); return (rows ?? []) - .map((r) => (Array.isArray(r.following) ? r.following[0] : r.following)) + .map((r) => (Array.isArray(r.follower) ? r.follower[0] : r.follower)) .filter((p): p is MessageUserResult => !!p && !blockedSet.has(p.id)); } @@ -81,6 +82,12 @@ export async function createGroupConversation(title: string, memberIds: string[] return { error: error?.message ?? "Could not create group" }; } + getPostHogServerClient()?.capture({ + distinctId: user.id, + event: "group_created", + properties: { size: new Set([user.id, ...memberIds]).size }, + }); + return { conversationId }; } diff --git a/app/(app)/onboarding/actions.ts b/app/(app)/onboarding/actions.ts index 57bd5563..19cfeb9d 100644 --- a/app/(app)/onboarding/actions.ts +++ b/app/(app)/onboarding/actions.ts @@ -3,6 +3,9 @@ import { redirect } from "next/navigation"; import { createClient } from "@/lib/supabase/server"; import { updateProfile, type EditState } from "@/app/(app)/profile/edit/actions"; +import { parseStepsDone } from "@/lib/onboarding"; +import { getPostHogServerClient } from "@/lib/posthog-server"; +import { getOwnerSettings, parsePublishFlags, saveOwnerSettings } from "@/lib/portfolio/owner"; function isRedirectSignal(err: unknown): boolean { return ( @@ -23,7 +26,40 @@ export async function saveOnboardingBasics(prev: EditState, formData: FormData): } } -export async function finishOnboarding(): Promise { +// Final onboarding step. Consent only: runs when the student leaves the +// pre-checked box on. Turns on intro, projects, experience, education and +// keeps every other flag and the section order the student already has +// (onboarding is revisitable from the feed checklist). +export async function savePortfolioConsent(_prev: EditState, formData: FormData): Promise { + if (formData.get("publish_portfolio") !== "on") return {}; + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { error: "You must be logged in." }; + const current = await getOwnerSettings(supabase, user.id); + if (!current.ok) return { error: "Could not publish your portfolio. You can do it later in Edit profile." }; + const flags = parsePublishFlags({ + publish_intro: true, + publish_projects: true, + publish_experience: true, + publish_education: true, + publish_activity: current.data.publish_activity, + publish_posts: current.data.publish_posts, + allow_indexing: current.data.allow_indexing, + }); + if (!flags.ok) return { error: "Could not publish your portfolio. You can do it later in Edit profile." }; + const { data: profile } = await supabase.from("profiles").select("is_private").eq("id", user.id).maybeSingle(); + // sectionOrder undefined keeps the saved order, so isPro is never consulted. + const saved = await saveOwnerSettings(supabase, user.id, flags.data, undefined, { + isPrivate: profile?.is_private ?? false, + isPro: false, + }); + if (!saved.ok) return { error: "Could not publish your portfolio. You can do it later in Edit profile." }; + return {}; +} + +export async function finishOnboarding(stepsDoneRaw: unknown = []): Promise { const supabase = await createClient(); const { data: { user }, @@ -31,5 +67,10 @@ export async function finishOnboarding(): Promise { if (!user) redirect("/login"); await supabase.from("profiles").update({ onboarded_at: new Date().toISOString() }).eq("id", user.id); + getPostHogServerClient()?.capture({ + distinctId: user.id, + event: "onboarding_completed", + properties: { steps_done: parseStepsDone(stepsDoneRaw) }, + }); redirect("/feed"); } diff --git a/app/(app)/post/[id]/actions.ts b/app/(app)/post/[id]/actions.ts index 986c52e8..f07b4d81 100644 --- a/app/(app)/post/[id]/actions.ts +++ b/app/(app)/post/[id]/actions.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache"; import { createClient } from "@/lib/supabase/server"; +import { getPostHogServerClient } from "@/lib/posthog-server"; import { TEXT_LIMITS, textLimitError } from "@/lib/utils/validation"; export type CommentState = { error?: string; ok?: boolean }; @@ -33,6 +34,21 @@ export async function createComment(_prev: CommentState, formData: FormData): Pr const { error } = await supabase.from("comments").insert({ post_id: postId, user_id: user.id, content }); if (error) return { error: "Could not post your comment. Try again." }; + // analytics only: never break the comment action + try { + const posthog = getPostHogServerClient(); + if (posthog) { + const { data: post } = await supabase.from("posts").select("context_label").eq("id", postId).maybeSingle(); + posthog.capture({ + distinctId: user.id, + event: "comment_created", + properties: { on_label: post?.context_label ?? null }, + }); + } + } catch { + // ignore + } + revalidatePath(`/post/${postId}`); return { ok: true }; } diff --git a/app/(app)/profile/[username]/page.tsx b/app/(app)/profile/[username]/page.tsx index c8c90b1c..77a46c0c 100644 --- a/app/(app)/profile/[username]/page.tsx +++ b/app/(app)/profile/[username]/page.tsx @@ -34,6 +34,7 @@ import TrackPortfolioView from "@/components/portfolio/TrackPortfolioView"; import { OwnerAnalyticsSection, PortfolioAnalyticsFallback } from "@/components/portfolio/PortfolioAnalytics"; import SharePortfolioButton from "@/components/portfolio/SharePortfolioButton"; import PortfolioBanner from "@/components/portfolio/PortfolioBanner"; +import ResumeLinks from "@/components/portfolio/ResumeLinks"; import UnavailableNotice from "@/components/portfolio/UnavailableNotice"; import { ActivitySection, @@ -44,7 +45,7 @@ import { PublicProjectList, } from "@/components/portfolio/ProfileSections"; const PROFILE_SELECT = - "id, username, display_name, avatar_url, banner_url, year, major, bio, goals, open_to, study_mode, is_private, heatmap_visibility, is_pro, pro_until, is_founder, is_campus_founder, profile_theme, verified_student, is_bot"; + "id, username, display_name, avatar_url, banner_url, year, major, bio, goals, open_to, study_mode, is_private, heatmap_visibility, is_pro, pro_until, is_founder, is_campus_founder, profile_theme, verified_student, is_bot, headline, github_url, linkedin_url, website_url"; const PROFILE_SELECT_FALLBACK = "id, username, display_name, avatar_url, banner_url, year, major, bio, goals, is_private, heatmap_visibility, is_pro, pro_until, is_founder, is_campus_founder, profile_theme, verified_student, is_bot"; @@ -399,7 +400,9 @@ async function PublicProfileView({ username }: { username: string }) {

@{profile.username}

+ {profile.headline &&

{profile.headline}

} {metaLine &&

{metaLine}

} +
@@ -496,6 +499,19 @@ export default async function ProfilePage({ const followState: FollowState = relRes.data?.status === "accepted" ? "following" : relRes.data?.status === "pending" ? "pending" : "none"; const contentHidden = (profile.is_private && !isOwner && !isAcceptedFollower) || isBlocked; + // Mirrors get_public_profile: hidden from viewers who cannot see content, + // and from the owner's public preview of a private account. + // The fallback select has no resume columns, so narrow each one. + const resumeText = (value: unknown) => (typeof value === "string" ? value : null); + const resume = + !contentHidden && !(isOwner && previewPublic && profile.is_private) && "headline" in profile + ? { + headline: resumeText(profile.headline), + github: "github_url" in profile ? resumeText(profile.github_url) : null, + linkedin: "linkedin_url" in profile ? resumeText(profile.linkedin_url) : null, + website: "website_url" in profile ? resumeText(profile.website_url) : null, + } + : null; const portfolioUnavailable = !bundle.ok && Boolean(bundle.unavailable); const projection = bundle.ok ? bundle.data.projection : null; const usePublicSections = !isOwner || previewPublic; @@ -689,7 +705,9 @@ export default async function ProfilePage({

@{profile.username}

+ {resume?.headline &&

{resume.headline}

} {metaLine &&

{metaLine}

} + {resume && }
@@ -699,6 +717,19 @@ export default async function ProfilePage({
+ {isOwner && + !previewPublic && + projection && + !projection.is_private && + !PORTFOLIO_SECTIONS.some((section) => publicSectionVisible(projection, section)) && ( +

+ Your shared link shows almost nothing.{" "} + + Publish your portfolio + +

+ )} + {portfolioUnavailable && isOwner && ( <> diff --git a/app/(app)/profile/edit/actions.ts b/app/(app)/profile/edit/actions.ts index 69b8137d..883a7243 100644 --- a/app/(app)/profile/edit/actions.ts +++ b/app/(app)/profile/edit/actions.ts @@ -9,7 +9,7 @@ import { getPostHogServerClient } from "@/lib/posthog-server"; import { DEGREE_VALUES as DEGREE_VALUES_RAW, pickPrimaryEducation } from "@/lib/education-options"; import { resolveInstitutionDomain } from "@/lib/resolve-domain"; import { isPortfolioSchemaMissing } from "@/lib/portfolio/errors"; -import { parseOpenTo, parseStudyMode } from "@/lib/portfolio/owner"; +import { parseOpenTo, parseResumeFields, parseStudyMode, type ResumeFields } from "@/lib/portfolio/owner"; // DEGREE_VALUES infers as a narrow string-literal union array (mapped from an // `as const` options list), which Array.includes can't check against a plain @@ -50,6 +50,19 @@ export async function updateProfile(_prev: EditState, formData: FormData): Promi if (!openTo.ok) return { error: openTo.unavailable ? openTo.message : openTo.error }; const studyMode = parseStudyMode(formData.get("study_mode")); if (!studyMode.ok) return { error: studyMode.unavailable ? studyMode.message : studyMode.error }; + // Only the edit form sends these. Onboarding step 1 also calls this action + // with display_name + bio only, and must not wipe a saved headline or links. + let resumeFields: ResumeFields | null = null; + if (formData.has("headline")) { + const parsed = parseResumeFields({ + headline: formData.get("headline"), + github_url: formData.get("github_url"), + linkedin_url: formData.get("linkedin_url"), + website_url: formData.get("website_url"), + }); + if (!parsed.ok) return { error: parsed.unavailable ? parsed.message : parsed.error }; + resumeFields = parsed.data; + } // Trust boundary: never take the client's word for Pro status. Non-Pro // requests simply don't touch profile_theme (a lapsed Pro keeps their @@ -60,7 +73,7 @@ export async function updateProfile(_prev: EditState, formData: FormData): Promi updates.profile_theme = isProfileTheme(themeRaw) ? themeRaw : null; } - const withOpenTo = { ...updates, open_to: openTo.data, study_mode: studyMode.data }; + const withOpenTo = { ...updates, open_to: openTo.data, study_mode: studyMode.data, ...(resumeFields ?? {}) }; const first = await supabase.from("profiles").update(withOpenTo).eq("id", user.id); if (first.error && isPortfolioSchemaMissing(first.error)) { const retry = await supabase.from("profiles").update(updates).eq("id", user.id); @@ -74,6 +87,16 @@ export async function updateProfile(_prev: EditState, formData: FormData): Promi // 7-day cooldown + dedupe live in the trigger) — nothing to call here. getPostHogServerClient()?.capture({ distinctId: user.id, event: "profile_updated" }); + if (resumeFields) { + getPostHogServerClient()?.capture({ + distinctId: user.id, + event: "resume_fields_updated", + properties: { + has_headline: resumeFields.headline !== null, + link_count: [resumeFields.github_url, resumeFields.linkedin_url, resumeFields.website_url].filter(Boolean).length, + }, + }); + } const { data: prof } = await supabase .from("profiles") diff --git a/app/(app)/profile/edit/page.tsx b/app/(app)/profile/edit/page.tsx index b4491624..939a99ec 100644 --- a/app/(app)/profile/edit/page.tsx +++ b/app/(app)/profile/edit/page.tsx @@ -17,7 +17,7 @@ export default async function EditProfilePage() { if (!user) redirect("/login"); const profileSelect = - "username, display_name, avatar_url, banner_url, year, major, bio, goals, open_to, study_mode, is_private, hide_school, heatmap_visibility, is_pro, pro_until, profile_theme"; + "username, display_name, avatar_url, banner_url, year, major, bio, goals, open_to, study_mode, is_private, hide_school, heatmap_visibility, is_pro, pro_until, profile_theme, headline, github_url, linkedin_url, website_url"; const profileFallback = "username, display_name, avatar_url, banner_url, year, major, bio, goals, is_private, hide_school, heatmap_visibility, is_pro, pro_until, profile_theme"; const firstProfile = await supabase.from("profiles").select(profileSelect).eq("id", user.id).single(); @@ -65,6 +65,10 @@ export default async function EditProfilePage() { school: schoolRow?.school ?? "", open_to: "open_to" in profile && Array.isArray(profile.open_to) ? profile.open_to : [], study_mode: "study_mode" in profile && typeof profile.study_mode === "string" ? profile.study_mode : null, + headline: "headline" in profile && typeof profile.headline === "string" ? profile.headline : null, + github_url: "github_url" in profile && typeof profile.github_url === "string" ? profile.github_url : null, + linkedin_url: "linkedin_url" in profile && typeof profile.linkedin_url === "string" ? profile.linkedin_url : null, + website_url: "website_url" in profile && typeof profile.website_url === "string" ? profile.website_url : null, }} /> {/* Experience + Education auto-save through their own actions; the single diff --git a/app/(app)/profile/projects/actions.ts b/app/(app)/profile/projects/actions.ts index 8686d5d1..b1fa3b56 100644 --- a/app/(app)/profile/projects/actions.ts +++ b/app/(app)/profile/projects/actions.ts @@ -10,11 +10,13 @@ import { deleteProject, parseProjectWrite, parsePublishFlags, + publishedSections, reorderProjects, saveOwnerSettings, setProjectStatus, updateProjectFields, } from "@/lib/portfolio/owner"; +import { getPostHogServerClient } from "@/lib/posthog-server"; import type { PortfolioClient } from "@/lib/portfolio/client"; import type { ProjectWriteInput } from "@/types/portfolio"; @@ -181,6 +183,14 @@ export async function savePublicationAction( { isPrivate: ctx.data.isPrivate, isPro: ctx.data.isPro } ); if (!saved.ok) return actionError(saved); + const sections = publishedSections(parsed.data); + if (sections.length > 0) { + getPostHogServerClient()?.capture({ + distinctId: ctx.data.userId, + event: "portfolio_published", + properties: { sections, is_private: ctx.data.isPrivate }, + }); + } revalidateProfile(ctx.data.username); return {}; } diff --git a/app/(app)/search/page.tsx b/app/(app)/search/page.tsx index 207786e4..145c1df6 100644 --- a/app/(app)/search/page.tsx +++ b/app/(app)/search/page.tsx @@ -16,7 +16,14 @@ import { searchHref, nextSearchOffset, } from "@/lib/search"; -import { hasDiscoveryFilters, hasPeopleFilters, parseDiscoveryFilters, postsDiscoveryHref } from "@/lib/discovery"; +import { + activeFacets, + hasDiscoveryFilters, + hasPeopleFilters, + parseDiscoveryFilters, + postsDiscoveryHref, +} from "@/lib/discovery"; +import { getPostHogServerClient } from "@/lib/posthog-server"; const POSTS_PREVIEW = 3; @@ -49,6 +56,15 @@ export default async function SearchPage({ data: { user }, } = await supabase.auth.getUser(); const viewerId = user?.id ?? null; + // First page only, so paging through results is not counted as a new filter use. + // ponytail: fires on every render of a filtered first page (refresh counts again); dedupe in PostHog if noisy. + if (user && peoplePage === 1 && projectPage === 1 && hasDiscoveryFilters(filters)) { + getPostHogServerClient()?.capture({ + distinctId: user.id, + event: "search_filtered", + properties: { facets: activeFacets(filters), has_query: hasQuery }, + }); + } const { data: viewer } = user ? await supabase.from("profiles").select("major").eq("id", user.id).maybeSingle() : { data: null }; diff --git a/components/onboarding/OnboardingWizard.tsx b/components/onboarding/OnboardingWizard.tsx index afff6a09..e5c2f9d6 100644 --- a/components/onboarding/OnboardingWizard.tsx +++ b/components/onboarding/OnboardingWizard.tsx @@ -11,12 +11,13 @@ import { type EducationState, } from "@/app/(app)/profile/edit/actions"; import { createPost, type ComposerState } from "@/app/(app)/feed/actions"; -import { saveOnboardingBasics, finishOnboarding } from "@/app/(app)/onboarding/actions"; +import { saveOnboardingBasics, finishOnboarding, savePortfolioConsent } from "@/app/(app)/onboarding/actions"; import AvatarBase from "@/components/ui/Avatar"; import SchoolAutocomplete from "@/components/profile/SchoolAutocomplete"; import DateRangePicker from "@/components/profile/DateRangePicker"; import Select from "@/components/ui/Select"; import { DEGREE_OPTIONS } from "@/lib/education-options"; +import type { OnboardingStep } from "@/lib/onboarding"; export type OnboardingProfile = { username: string; @@ -40,7 +41,9 @@ const label = "block text-sm font-medium text-[var(--ink)]"; const field = "input-base mt-1.5"; export default function OnboardingWizard({ profile }: { profile: OnboardingProfile }) { - const [step, setStep] = useState<1 | 2 | 3 | 4>(1); + const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1); + const [stepsDone, setStepsDone] = useState([]); + const markDone = (s: OnboardingStep) => setStepsDone((prev) => (prev.includes(s) ? prev : [...prev, s])); const reduce = useReducedMotion(); const currentYear = new Date().getFullYear(); @@ -66,7 +69,10 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi startBasics(async () => { const result = await saveOnboardingBasics({}, fd); if (result.error) setBasicsError(result.error); - else setStep(2); + else { + markDone("basics"); + setStep(2); + } }); } @@ -77,7 +83,7 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi function onFinish() { startFinish(async () => { - await finishOnboarding(); + await finishOnboarding(stepsDone); }); } @@ -91,7 +97,10 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi startPost(async () => { const result: ComposerState = await createPost({}, fd); if (result.error) setPostError(result.error); - else setStep(3); + else { + markDone("post"); + setStep(3); + } }); } @@ -105,7 +114,10 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi startEdu(async () => { const result: EducationState = await addEducation({}, fd); if (result.error) setEduError(result.error); - else setStep(4); + else { + markDone("education"); + setStep(4); + } }); } @@ -119,7 +131,24 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi startExp(async () => { const result: ExperienceState = await addExperience({}, fd); if (result.error) setExpError(result.error); - else await finishOnboarding(); + else { + markDone("experience"); + setStep(5); + } + }); + } + + const [publishPending, startPublish] = useTransition(); + const [publishError, setPublishError] = useState(); + + function onSubmitPublish(e: React.FormEvent) { + e.preventDefault(); + const fd = new FormData(e.currentTarget); + setPublishError(undefined); + startPublish(async () => { + const result = await savePortfolioConsent({}, fd); + if (result.error) setPublishError(result.error); + else await finishOnboarding(stepsDone); }); } @@ -128,11 +157,11 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi

Set up your profile

-

Optional · step {step} of 4

+

Optional · step {step} of 5

@@ -300,11 +329,31 @@ export default function OnboardingWizard({ profile }: { profile: OnboardingProfi
- +
+ + )} + + {step === 5 && ( +
+

Make your portfolio public

+

+ Your link samehere.dev/profile/{profile.username} shows your intro, projects, experience, and education. + Activity and posts stay private. You can change this anytime in Edit profile. +

+ {publishError &&

{publishError}

} + +
+
diff --git a/components/portfolio/PublicationControls.tsx b/components/portfolio/PublicationControls.tsx index fa9c0d57..465e4b79 100644 --- a/components/portfolio/PublicationControls.tsx +++ b/components/portfolio/PublicationControls.tsx @@ -50,7 +50,7 @@ export default function PublicationControls({ } return ( -
+

Publication

Existing sections stay private until you turn them on. A private account still hides them from the public page. diff --git a/components/portfolio/ResumeLinks.tsx b/components/portfolio/ResumeLinks.tsx new file mode 100644 index 00000000..bb8e95fb --- /dev/null +++ b/components/portfolio/ResumeLinks.tsx @@ -0,0 +1,35 @@ +import { Github, Globe, Linkedin } from "lucide-react"; +import { httpUrlError } from "@/lib/portfolio/validation"; + +type ResumeLinksProps = { github: string | null; linkedin: string | null; website: string | null }; + +const LINKS = [ + { key: "github", label: "GitHub", Icon: Github }, + { key: "linkedin", label: "LinkedIn", Icon: Linkedin }, + { key: "website", label: "Website", Icon: Globe }, +] as const; + +export default function ResumeLinks(props: ResumeLinksProps) { + const items = LINKS.flatMap((link) => { + const href = props[link.key]; + return href && !httpUrlError(link.label, href) ? [{ ...link, href }] : []; + }); + if (items.length === 0) return null; + return ( +

    + {items.map(({ key, label, Icon, href }) => ( +
  • + + + +
  • + ))} +
+ ); +} diff --git a/components/portfolio/SharePortfolioButton.tsx b/components/portfolio/SharePortfolioButton.tsx index 4fab1b03..fa346f3d 100644 --- a/components/portfolio/SharePortfolioButton.tsx +++ b/components/portfolio/SharePortfolioButton.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { motion, useReducedMotion } from "motion/react"; import { Check, Share2 } from "lucide-react"; +import posthog from "posthog-js"; import { ICON_SPRING } from "@/lib/motion/spring"; import { profileShareUrl } from "@/lib/portfolio/share"; @@ -21,6 +22,7 @@ export default function SharePortfolioButton({ if (typeof navigator !== "undefined" && typeof navigator.share === "function") { try { await navigator.share({ title: `${displayName} on samehere`, url, text: `@${username}` }); + posthog.capture("portfolio_shared", { method: "native" }); return; } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; @@ -28,6 +30,7 @@ export default function SharePortfolioButton({ } try { await navigator.clipboard.writeText(url); + posthog.capture("portfolio_shared", { method: "copy" }); setCopied(true); window.setTimeout(() => setCopied(false), 1600); } catch { diff --git a/components/profile/EditProfileForm.tsx b/components/profile/EditProfileForm.tsx index cb6bdf99..12bd595a 100644 --- a/components/profile/EditProfileForm.tsx +++ b/components/profile/EditProfileForm.tsx @@ -31,6 +31,10 @@ export type EditInitial = { is_pro: boolean; pro_until: string | null; profile_theme: string | null; + headline: string | null; + github_url: string | null; + linkedin_url: string | null; + website_url: string | null; }; const label = "block text-sm font-medium text-[var(--ink)]"; @@ -150,6 +154,13 @@ export default function EditProfileForm({ initial }: { initial: EditInitial }) { defaultValue={initial.display_name ?? ""} placeholder="Your name" className={field} /> +
+ + +

One line under your name on your portfolio.

+
+