feat(02-06): milestone-meta map + MilestoneCard + PostCard MILESTONE routing — MILE-02

This commit is contained in:
admin committed 2026-06-13 10:21:49 +02:00
1 parent 554ca1deae
commit 9b02d3dc8b
3 files changed
+163 -8

No files matched your search

+94
View File
@@ -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 (
<article className="bg-orange-50 border border-orange-200 rounded-lg overflow-hidden">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3">
<Avatar className="w-8 h-8 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={pet.name} />}
<AvatarFallback className="text-xs bg-orange-100 text-orange-600">
{pet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-base font-semibold text-foreground leading-tight truncate">
{pet.name}
</p>
<p className="text-xs text-muted-foreground">{formatDate(post.createdAt)}</p>
</div>
</div>
{/* Milestone type badge */}
<div className="flex items-center gap-3 px-4 pb-3">
<Icon className="h-8 w-8 text-orange-500 shrink-0" aria-hidden="true" />
<span className="text-base font-semibold text-orange-500">{meta.label}</span>
</div>
{/* Optional photo */}
{photo && (
<div className="w-full">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getMediaUrl(photo.storageKey) ?? ""}
alt={caption ?? `${pet.name}'s milestone`}
className="w-full object-cover max-h-[400px]"
/>
</div>
)}
{/* Caption or notes */}
{(caption || milestone.notes) && (
<div className="px-4 py-3 space-y-1">
{caption && <p className="text-sm text-foreground">{caption}</p>}
{milestone.notes && (
<p className="text-sm text-muted-foreground italic">{milestone.notes}</p>
)}
</div>
)}
</article>
);
}
+17 -8
View File
@@ -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 <MilestoneCard post={post} />;
}
return (
<article className="bg-card border border-border rounded-lg overflow-hidden">
{/* Header row — 16px horizontal padding, 12px vertical */}
+52
View File
@@ -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<MilestoneType, MilestoneMeta> = {
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;