diff --git a/src/components/feed/MilestoneCard.tsx b/src/components/feed/MilestoneCard.tsx new file mode 100644 index 0000000..4674f82 --- /dev/null +++ b/src/components/feed/MilestoneCard.tsx @@ -0,0 +1,94 @@ +"use client"; + +import type { MilestoneType } from "@prisma/client"; +import { MILESTONE_META } from "@/lib/milestone-meta"; +import { getAvatarUrl } from "@/lib/avatar-url"; +import { getMediaUrl } from "@/lib/media-url"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; + +type MilestoneCardPost = { + id: string; + caption: string | null; + createdAt: Date | string; + pet: { id: string; name: string; avatarKey: string | null }; + images: { id: string; storageKey: string; position: number }[]; + milestone: { milestoneType: MilestoneType; notes: string | null } | null; +}; + +interface MilestoneCardProps { + post: MilestoneCardPost; +} + +function formatDate(date: Date | string): string { + return new Date(date).toLocaleDateString(undefined, { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +/** + * MilestoneCard — distinct orange-tinted card for milestone posts (MILE-02). + * + * Renders: icon (32px, orange-500) + type label (text-base font-semibold orange-500) + * + pet name + date + optional caption/notes + optional photo. + * Background: bg-orange-50 border border-orange-200 per UI-SPEC accent reservation #8. + */ +export function MilestoneCard({ post }: MilestoneCardProps) { + const { pet, milestone, images, caption } = post; + + if (!milestone) return null; + + const meta = MILESTONE_META[milestone.milestoneType]; + const Icon = meta.icon; + const avatarUrl = getAvatarUrl(pet.avatarKey); + const photo = images[0]; + + return ( +
+ {/* Header */} +
+ + {avatarUrl && } + + {pet.name[0]?.toUpperCase() ?? "?"} + + +
+

+ {pet.name} +

+

{formatDate(post.createdAt)}

+
+
+ + {/* Milestone type badge */} +
+
+ + {/* Optional photo */} + {photo && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {caption +
+ )} + + {/* Caption or notes */} + {(caption || milestone.notes) && ( +
+ {caption &&

{caption}

} + {milestone.notes && ( +

{milestone.notes}

+ )} +
+ )} +
+ ); +} diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx index 0114ea0..2bd4df0 100644 --- a/src/components/feed/PostCard.tsx +++ b/src/components/feed/PostCard.tsx @@ -37,6 +37,7 @@ import { Button } from "@/components/ui/button"; import { getAvatarUrl } from "@/lib/avatar-url"; import { getMediaUrl } from "@/lib/media-url"; import { PawRing } from "@/components/stories/PawRing"; +import { MilestoneCard } from "@/components/feed/MilestoneCard"; import { BlockDialog } from "@/components/safety/BlockDialog"; import { ReportSheet } from "@/components/safety/ReportSheet"; @@ -67,15 +68,18 @@ type PostPet = { avatarKey: string | null; }; +export type PostCardPost = { + id: string; + type?: string | null; + caption: string | null; + createdAt: Date | string; + pet: PostPet; + images: PostImage[]; + milestone: { milestoneType: import("@prisma/client").MilestoneType; notes: string | null } | null; +}; + export type PostCardProps = { - post: { - id: string; - caption: string | null; - createdAt: Date | string; // tRPC serializes Date as ISO string over HTTP - pet: PostPet; - images: PostImage[]; - milestone: unknown | null; - }; + post: PostCardPost; /** Called after successful delete so parent can remove from list */ onDeleted?: (postId: string) => void; }; @@ -180,6 +184,11 @@ export function PostCard({ post, onDeleted }: PostCardProps) { deleteMutation.mutate({ postId: post.id }); }, [post.id, deleteMutation]); + // Milestone posts use their own distinct card (MILE-02) + if (post.type === "MILESTONE") { + return ; + } + return (
{/* Header row — 16px horizontal padding, 12px vertical */} diff --git a/src/lib/milestone-meta.ts b/src/lib/milestone-meta.ts new file mode 100644 index 0000000..a3818c5 --- /dev/null +++ b/src/lib/milestone-meta.ts @@ -0,0 +1,52 @@ +import { MilestoneType } from "@prisma/client"; +import { + Cake, + Heart, + Leaf, + Stethoscope, + Milk, + Trophy, + Star, + Award, + type LucideIcon, +} from "lucide-react"; + +export interface MilestoneMeta { + label: string; + icon: LucideIcon; +} + +/** + * Maps every MilestoneType to a display label and Lucide icon (D-09, UI-SPEC). + * Used by MilestoneCard and MilestoneForm. + */ +export const MILESTONE_META: Record = { + BIRTHDAY: { label: "Birthday", icon: Cake }, + ADOPTION_DAY: { label: "Adoption Day", icon: Heart }, + FIRST_OUTING: { label: "First Outing", icon: Leaf }, + VET_VISIT: { label: "Vet Visit", icon: Stethoscope }, + LITTER: { label: "New Litter", icon: Milk }, + FOLLOWERS_500: { label: "500 Followers", icon: Trophy }, + FOLLOWERS_1K: { label: "1,000 Followers", icon: Trophy }, + FOLLOWERS_2_5K: { label: "2,500 Followers", icon: Trophy }, + FOLLOWERS_5K: { label: "5,000 Followers", icon: Trophy }, + FOLLOWERS_10K: { label: "10,000 Followers", icon: Star }, + FOLLOWERS_25K: { label: "25,000 Followers", icon: Star }, + FOLLOWERS_50K: { label: "50,000 Followers", icon: Star }, + FOLLOWERS_100K: { label: "100,000 Followers", icon: Star }, + FOLLOWERS_250K: { label: "250,000 Followers", icon: Award }, + FOLLOWERS_500K: { label: "500,000 Followers", icon: Award }, + FOLLOWERS_1M: { label: "1,000,000 Followers", icon: Award }, +}; + +/** + * The 5 user-initiated milestone types shown in the creation picker. + * Follower-threshold types are auto-triggered (D-10) and excluded from the form. + */ +export const USER_INITIATED_MILESTONE_TYPES = [ + MilestoneType.BIRTHDAY, + MilestoneType.ADOPTION_DAY, + MilestoneType.FIRST_OUTING, + MilestoneType.VET_VISIT, + MilestoneType.LITTER, +] as const;