feat(02-06): milestone-meta map + MilestoneCard + PostCard MILESTONE routing — MILE-02
This commit is contained in:
1 parent
554ca1deae
commit
9b02d3dc8b
3 files changed
+163
-8
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user