diff --git a/app/(app)/(reader)/disciples/disciples-browser.tsx b/app/(app)/(reader)/disciples/disciples-browser.tsx index d43df87e..f8f5482a 100644 --- a/app/(app)/(reader)/disciples/disciples-browser.tsx +++ b/app/(app)/(reader)/disciples/disciples-browser.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; +import { UserAvatar } from "@/components/ui/user-avatar"; import { cn } from "@/lib/utils"; import { Search, @@ -32,6 +33,14 @@ export type DiscipleSummary = { isVerified: boolean; }; +/** A disciple who has a platform account (linked by an admin). */ +export type DiscipleLink = { + discipleId: string; + handle: string; + name: string; + avatarURL: string | null; +}; + export type LetterCount = { letter: string; count: number }; export type PlaceGroup = { name: string; count: number }; export type YearGroup = { year: number; count: number }; @@ -49,6 +58,9 @@ type Props = { years: YearGroup[]; totalCount: number; filteredCount: number; + /** Disciples who have joined the platform — their cards are highlighted and + * link through to the profile. Small set (dozens at most). */ + links: DiscipleLink[]; }; type SortMode = "name-asc" | "name-desc" | "date-asc" | "date-desc" | "place"; @@ -306,6 +318,97 @@ function Pagination({ ); } +// --------------------------------------------------------------------------- +// Disciple card +// --------------------------------------------------------------------------- + +/** + * One disciple in the grid. + * + * When `link` is set the disciple has joined the platform, and the card is + * highlighted and becomes a link to their profile. The highlight is drawn + * entirely from theme tokens (`--primary`, `--ring`) rather than fixed colours, + * so it picks up whichever palette the reader has chosen — peacock green under + * Tulasī Garden, deep indigo under Yamunā Mist, and so on. + */ +function DiscipleCard({ + disciple, + link, +}: { + disciple: DiscipleSummary; + link?: DiscipleLink; +}) { + const locationYear = formatLocationYear( + disciple.initiationPlace, + disciple.initiationDate, + ); + + const card = ( + + +
+

+ {disciple.initiatedName} +

+ {disciple.suffix && ( + + {disciple.suffix} + + )} +
+ {locationYear && ( +

+ {locationYear} +

+ )} + {link && ( +
+ + + @{link.handle} + +
+ )} +
+
+ ); + + if (!link) return card; + + return ( + + {card} + + ); +} + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- @@ -318,6 +421,7 @@ export function DisciplesBrowser({ suffixCounts, totalCount, filteredCount, + links, }: Props) { const [query, setQuery] = useState(""); const [sort, setSort] = useState("name-asc"); @@ -334,6 +438,13 @@ export function DisciplesBrowser({ setPage(1); }; + // Disciple id → their platform account, for the highlight + profile link. + const linkByDiscipleId = useMemo(() => { + const map = new Map(); + for (const l of links) map.set(l.discipleId, l); + return map; + }, [links]); + // Build a lookup map for letter counts const countByLetter = useMemo(() => { const map = new Map(); @@ -539,41 +650,13 @@ export function DisciplesBrowser({ ) : (
- {pageData.map((disciple) => { - const locationYear = formatLocationYear( - disciple.initiationPlace, - disciple.initiationDate, - ); - - return ( - - -
-

- {disciple.initiatedName} -

- {disciple.suffix && ( - - {disciple.suffix} - - )} -
- {locationYear && ( -

- {locationYear} -

- )} -
-
- ); - })} + {pageData.map((disciple) => ( + + ))}
)} diff --git a/app/(app)/(reader)/disciples/page.tsx b/app/(app)/(reader)/disciples/page.tsx index 8a476af2..d5eb9e05 100644 --- a/app/(app)/(reader)/disciples/page.tsx +++ b/app/(app)/(reader)/disciples/page.tsx @@ -12,6 +12,7 @@ import { getDiscipleYears, getDiscipleSuffixCounts, getFilteredDiscipleCount, + getDiscipleLinks, } from "@/lib/disciples"; import type { SuffixFilter } from "@/lib/disciples"; import { DisciplesBrowser } from "./disciples-browser"; @@ -62,6 +63,7 @@ export default async function DisciplesPage({ places, years, suffixCounts, + links, ] = await Promise.all([ getTotalDiscipleCount(), getFilteredDisciples(letterFilter, activeSuffix), @@ -70,6 +72,7 @@ export default async function DisciplesPage({ getDisciplePlaces(), getDiscipleYears(), getDiscipleSuffixCounts(), + getDiscipleLinks(), ]); // Serialize dates to ISO strings for the client component @@ -105,6 +108,7 @@ export default async function DisciplesPage({ years={years} totalCount={totalCount} filteredCount={filteredCount} + links={links} /> diff --git a/app/(app)/notifications/page.tsx b/app/(app)/notifications/page.tsx index c41652be..26ce2305 100644 --- a/app/(app)/notifications/page.tsx +++ b/app/(app)/notifications/page.tsx @@ -350,6 +350,8 @@ function messageFor(n: AppNotification): string { return "replied to your comment."; case "comment_mention": return "mentioned you in a comment."; + case "disciple_joined": + return "— a direct disciple of Śrīla Prabhupāda — has joined Prabhupāda Says."; case "admin_message": return n.body ?? "sent you a message."; default: diff --git a/app/(app)/u/[handle]/layout.tsx b/app/(app)/u/[handle]/layout.tsx index 2c131eba..212d64c2 100644 --- a/app/(app)/u/[handle]/layout.tsx +++ b/app/(app)/u/[handle]/layout.tsx @@ -129,6 +129,7 @@ export default function PublicProfileLayout(props: { interactiveCounts={false} joinedAt={data.joined_at} country={data.country} + isDirectDisciple={!!data.is_direct_disciple} />

@@ -174,6 +175,7 @@ export default function PublicProfileLayout(props: { enableActions joinedAt={data.joined_at} country={data.country} + isDirectDisciple={!!data.is_direct_disciple} /> diff --git a/app/(app)/u/me/layout.tsx b/app/(app)/u/me/layout.tsx index d438dfa1..701f532d 100644 --- a/app/(app)/u/me/layout.tsx +++ b/app/(app)/u/me/layout.tsx @@ -140,6 +140,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) { name={user.name} handle={user.handle} joinedAt={user.created_at} + isDirectDisciple={!!user.is_direct_disciple} editHref="/settings/profile" postCount={counts?.posts} followerCount={counts?.followers} diff --git a/components/notifications/notification-matrix.tsx b/components/notifications/notification-matrix.tsx index c8b5d0b1..6c29a542 100644 --- a/components/notifications/notification-matrix.tsx +++ b/components/notifications/notification-matrix.tsx @@ -26,7 +26,15 @@ import { pushSupported, } from "@/lib/push"; -const EVENTS: { key: PrefEvent; label: string; description: string }[] = [ +const EVENTS: { + key: PrefEvent; + label: string; + description: string; + /** Channels this event can actually be delivered on. Omit for all three. + * A cell outside this set renders as "—" rather than a live toggle, so we + * never show a switch that silently does nothing. */ + channels?: PrefChannel[]; +}[] = [ { key: "follows", label: "Follows", @@ -42,6 +50,14 @@ const EVENTS: { key: PrefEvent; label: string; description: string }[] = [ label: "Sādhana reminders", description: "Daily nudges to chant your rounds and share a verse.", }, + { + key: "community", + label: "Community", + description: + "Rare announcements — such as a direct disciple of Śrīla Prabhupāda joining.", + // Announcements are in-app/push only; we deliberately don't email them. + channels: ["in_app", "push"], + }, ]; const CHANNELS: { key: PrefChannel; label: string }[] = [ @@ -73,7 +89,9 @@ export function NotificationMatrix() { async function toggle(event: PrefEvent, channel: PrefChannel) { if (!prefs) return; - const next = !prefs[event][channel]; + // Default-on when the key is absent: prefs are stored as opt-outs, and a + // BFF that predates this category simply omits it. + const next = !(prefs[event]?.[channel] ?? true); setBusyCell(`${event}/${channel}`); try { // Turning a push cell ON only helps if this device can receive push — @@ -132,23 +150,34 @@ export function NotificationMatrix() { {event.description}

- {CHANNELS.map((channel) => ( -
- {prefs ? ( - toggle(event.key, channel.key)} - label={`${event.label} — ${channel.label}`} - /> - ) : ( - … - )} -
- ))} + {CHANNELS.map((channel) => { + const deliverable = + !event.channels || event.channels.includes(channel.key); + return ( +
+ {!deliverable ? ( + + — + + ) : prefs ? ( + toggle(event.key, channel.key)} + label={`${event.label} — ${channel.label}`} + /> + ) : ( + … + )} +
+ ); + })} ))} diff --git a/components/notifications/notifications-provider.tsx b/components/notifications/notifications-provider.tsx index 2d808c49..435616c5 100644 --- a/components/notifications/notifications-provider.tsx +++ b/components/notifications/notifications-provider.tsx @@ -70,6 +70,8 @@ function messageFor(e: IncomingEvent): string { return `${who} replied to your comment`; case "comment_mention": return `${who} mentioned you in a comment`; + case "disciple_joined": + return `${who}, a direct disciple of Śrīla Prabhupāda, has joined`; case "admin_message": return e.body || "A message from Prabhupāda Says"; default: diff --git a/components/profile/disciple-badge.tsx b/components/profile/disciple-badge.tsx new file mode 100644 index 00000000..921c3051 --- /dev/null +++ b/components/profile/disciple-badge.tsx @@ -0,0 +1,89 @@ +"use client"; + +/** + * DiscipleBadge — the small circular Śrīla Prabhupāda portrait that marks a + * profile as belonging to one of his own initiated disciples. + * + * Positioned by the caller (the profile header overlaps it on the avatar's + * lower-right, like a verification tick). Tapping it toggles a tooltip reading + * "Śrīla Prabhupāda's Direct Disciple" — click rather than hover, so it works + * identically on touch, where there is no hover state. + * + * The tooltip closes on a second tap, on Escape, or on any outside click. + */ + +import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import { cn } from "@/lib/utils"; + +const LABEL = "Śrīla Prabhupāda's Direct Disciple"; + +export function DiscipleBadge({ className }: { className?: string }) { + const [open, setOpen] = useState(false); + const wrapRef = useRef(null); + + // Dismiss on outside click / Escape. Bound only while open so we don't keep + // document-level listeners around for every profile view. + useEffect(() => { + if (!open) return; + function onPointerDown(e: PointerEvent) { + if (!wrapRef.current?.contains(e.target as Node)) setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("pointerdown", onPointerDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + return ( + + + + {open ? ( + + {/* Caret pointing back at the badge. */} + + {LABEL} + + ) : null} + + ); +} diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx index 989c006f..d935a457 100644 --- a/components/profile/profile-header.tsx +++ b/components/profile/profile-header.tsx @@ -34,6 +34,7 @@ import { Settings, X } from "lucide-react"; import { cn } from "@/lib/utils"; import type { RelationshipResult, RelationshipState } from "@/lib/social"; import { ProfileActions } from "@/components/profile/profile-actions"; +import { DiscipleBadge } from "@/components/profile/disciple-badge"; import { UserAvatar } from "@/components/ui/user-avatar"; import { FollowListDrawer, @@ -53,6 +54,7 @@ export function ProfileHeader({ enableActions = false, country, interactiveCounts = true, + isDirectDisciple = false, }: { avatarURL: string | null; name: string; @@ -81,6 +83,10 @@ export function ProfileHeader({ * click target) and the follow-list drawer isn't mounted — used on a * blocked profile, where the lists endpoint is gated server-side. */ interactiveCounts?: boolean; + /** Set when an admin has linked this account to the disciples directory — + * one of Śrīla Prabhupāda's own initiated disciples. Overlays his portrait + * on the avatar; tapping it explains the mark. */ + isDirectDisciple?: boolean; }) { // Owner affordances ride on `editHref`; the follow-action row rides on // `enableActions`. Mutually exclusive by construction. @@ -111,15 +117,22 @@ export function ProfileHeader({ return ( <>
- setZoomOpen(true) : undefined} - ariaLabel={`View ${name}'s profile picture`} - /> + {/* Avatar, with Śrīla Prabhupāda's portrait tucked into the lower-right + for his direct disciples (verification-tick placement). */} +
+ setZoomOpen(true) : undefined} + ariaLabel={`View ${name}'s profile picture`} + /> + {isDirectDisciple ? ( + + ) : null} +

diff --git a/lib/auth.ts b/lib/auth.ts index fe2f118f..8f1a643d 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -33,6 +33,9 @@ export type User = { * the "Change password" modal and the "Set a password" flow. */ has_password: boolean; + /** True when an admin has linked this account to the disciples directory — + * one of Śrīla Prabhupāda's own initiated disciples. Admin-set only. */ + is_direct_disciple?: boolean; /** Account country as an ISO-3166-1 alpha-2 code (e.g. "IN"), or null * when unknown. Captured best-effort at signup; user-editable. */ country: string | null; diff --git a/lib/disciples.ts b/lib/disciples.ts index 28271217..11e91500 100644 --- a/lib/disciples.ts +++ b/lib/disciples.ts @@ -41,6 +41,19 @@ export type YearGroup = { export type SuffixFilter = "all" | "Dasa" | "Devi Dasi"; +/** + * A disciple who has a platform account, linked by an admin. + * + * The disciple records live in Mongo while accounts live in Postgres, so the + * link is resolved server-side by the BFF and handed over as this flat pair. + */ +export type DiscipleLink = { + discipleId: string; + handle: string; + name: string; + avatarURL: string | null; +}; + // --- BFF response types (snake_case from Go API) --- type BffDisciple = { @@ -76,6 +89,42 @@ function mapDisciple(d: BffDisciple): DiscipleSummary { }; } +// --- Linked platform accounts --- + +type BffDiscipleLink = { + disciple_id: string; + handle: string; + name: string; + avatar_url: string | null; +}; + +/** + * Get every disciple who has a platform account. + * + * Deliberately unpaged — only Śrīla Prabhupāda's own disciples are ever + * linked, so this is a few dozen rows at most and the browser fetches the + * whole set once to highlight the matching cards. + * + * Fails soft: the highlight is decorative, so a BFF hiccup (or a rolling + * deploy where the frontend is ahead of the API) must not take the whole + * directory page down with it. `bff` already maps 404 → null; here we also + * swallow 5xx and network errors and fall back to "no highlights". + */ +export async function getDiscipleLinks(): Promise { + try { + const links = await bffList("/disciples/linked"); + return links.map((l) => ({ + discipleId: l.disciple_id, + handle: l.handle, + name: l.name, + avatarURL: l.avatar_url ?? null, + })); + } catch (err) { + console.error("getDiscipleLinks failed; rendering without highlights", err); + return []; + } +} + // --- Count --- /** Get total number of disciples in the collection. */ diff --git a/lib/social.ts b/lib/social.ts index 1a43a358..b337d871 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -192,6 +192,9 @@ export type PublicProfile = { follower_count: number; following_count: number; relationship: RelationshipState | null; + /** True when an admin has linked this account to the disciples directory — + * i.e. one of Śrīla Prabhupāda's own initiated disciples. */ + is_direct_disciple?: boolean; public_collections: Collection[]; }; @@ -698,7 +701,11 @@ export const notifications = { // ─── Notification preferences ────────────────────────────────────────── -export type PrefEvent = "follows" | "post_interactions" | "sadhna_reminders"; +export type PrefEvent = + | "follows" + | "post_interactions" + | "sadhna_reminders" + | "community"; export type PrefChannel = "in_app" | "email" | "push"; /** event → channel → enabled, as resolved by the BFF (defaults on). */ export type NotificationPrefs = Record>; diff --git a/public/images/prabhupada-disciple-badge.png b/public/images/prabhupada-disciple-badge.png new file mode 100644 index 00000000..9b2f9324 Binary files /dev/null and b/public/images/prabhupada-disciple-badge.png differ