feat(feed): add birthday highlight for pets in feed and explore

- display subtle birthday marker on posts when pet has birthday
- highlight birthday posts with soft accent border
- reuse central petAge utilities for consistent logic
- preserve all existing post meta information (author, time)
- avoid layout shifts by using decorative indicators only
This commit is contained in:
admin committed 2025-12-25 12:45:14 +01:00
1 parent 13f2dedf13
commit aba22e2855
6 files changed
+226 -267

No files matched your search

+31 -10
View File
@@ -1,6 +1,7 @@
'use client';
import { useRef } from 'react';
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
export type PostCardData = {
id: number;
@@ -31,6 +32,7 @@ export default function PostCard({
}: PostCardProps) {
const likeRef = useRef<HTMLButtonElement | null>(null);
const commentRef = useRef<HTMLButtonElement | null>(null);
const isBirthday = post.petBirthDate && isBirthdayToday(post.petBirthDate);
function animate(
ref: React.RefObject<HTMLButtonElement>,
@@ -50,11 +52,13 @@ export default function PostCard({
return (
<article
className={`postcard ${
isDiscovery ? 'postcard--discovery' : ''
}`}
className={`postcard
${isDiscovery ? 'postcard--discovery' : ''}
${isBirthday ? 'postcard--birthday' : ''}
`}
>
{/* Klickbarer Bereich → Preview */}
<div
className="postcard-main"
@@ -70,14 +74,31 @@ export default function PostCard({
</div>
)}
<div className="postcard-title-row">
<strong className="postcard-title">
{post.petName}
</strong>
<span className="postcard-meta">
· {post.petType} · {post.authorName} · {post.createdAt}
<div className="postcard-title-row">
<strong className="postcard-title">
{post.petName}
</strong>
<span className="postcard-meta">
· {post.petType}
{post.petBirthDate && ` · ${formatPetAge(post.petBirthDate)}`}
· {post.authorName}
· {post.createdAt}
</span>
{isBirthday && (
<span
className="birthday-dot"
title="Heute ist Geburtstag 🎉"
aria-label="Geburtstag"
>
🎂
</span>
</div>
)}
</div>
</div>
<p className="postcard-text">{post.text}</p>
+1
View File
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
import PostCard, {
PostCardData,
} from '@/app/components/PostCard/PostCard';
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
/* =======================
MOCK DATA
+1
View File
@@ -4,6 +4,7 @@ import { useRef, useState } from 'react';
import PostCard, {
PostCardData,
} from '@/app/components/PostCard/PostCard';
import { formatPetAge, isBirthdayToday } from '@/app/lib/petAge';
/* =======================
MOCK: USER KONTEXT
+27
View File
@@ -721,3 +721,30 @@ select:disabled {
color: var(--color-text-muted);
cursor: not-allowed;
}
.postcard-title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.birthday-dot {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 12px;
background: var(--color-primary-soft);
border: 1px solid rgba(46, 125, 50, 0.25);
box-shadow: var(--shadow-sm);
font-size: 0.95rem;
}
/* Birthday Highlight */
.postcard--birthday {
border-color: rgba(46, 125, 50, 0.35);
box-shadow:
0 0 0 2px rgba(46, 125, 50, 0.18),
var(--shadow-sm);
}
+162 -257
View File
@@ -1,326 +1,231 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { dogBreeds, catBreeds } from '@/app/data/breeds';
type PetType = 'Hund' | 'Katze';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
formatPetAge,
} from '@/app/lib/petAge';
/* Mock – später API */
type Pet = {
id: number;
name: string;
type: PetType;
type: 'Hund' | 'Katze';
breed: string;
birthDate?: string;
gender?: 'Männlich' | 'Weiblich' | 'Unbekannt';
bio?: string;
visibility: 'public' | 'followers';
};
// 🔧 Mock-Daten (später Backend)
/* MOCK-DATEN */
const mockPets: Pet[] = [
{
id: 1,
name: 'Luna',
type: 'Katze',
breed: 'Bengal x Mix',
bio: 'Neugierig, verspielt und liebt Sonne.',
},
{
id: 2,
name: 'Bello',
type: 'Hund',
breed: 'Labrador Retriever x Mix',
bio: 'Immer gute Laune.',
breed: 'Bengal',
birthDate: '2021-05-14',
gender: 'Weiblich',
bio: 'Neugierig und verspielt',
visibility: 'public',
},
];
type PageProps = {
params: Promise<{ id: string }>;
};
export default async function EditPetPage({ params }: PageProps) {
const { id } = await params;
const petId = Number(id);
export default function EditPetPage({
params,
}: {
params: { id: string };
}) {
const router = useRouter();
const petId = Number(params.id);
const pet = mockPets.find((p) => p.id === petId);
if (!pet) {
return <p>Haustier nicht gefunden.</p>;
}
return <EditPetForm pet={pet} />;
}
/* ---------- FORMULAR (Client Component) ---------- */
function EditPetForm({ pet }: { pet: Pet }) {
const [name, setName] = useState(pet.name);
const [type, setType] = useState<PetType>(pet.type);
const [breed, setBreed] = useState(pet.breed);
const [breedSearch, setBreedSearch] = useState('');
const [bio, setBio] = useState(pet.bio ?? '');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const breeds = type === 'Hund' ? dogBreeds : catBreeds;
const filteredBreeds = breeds.filter((b) =>
b.toLowerCase().includes(breedSearch.toLowerCase())
const [name, setName] = useState('');
const [birthDate, setBirthDate] = useState('');
const [ageFallback, setAgeFallback] = useState('');
const [gender, setGender] = useState<'Männlich' | 'Weiblich' | 'Unbekannt'>(
'Unbekannt'
);
const [bio, setBio] = useState('');
const [visibility, setVisibility] = useState<'public' | 'followers'>(
'public'
);
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
/* Initialwerte setzen */
useEffect(() => {
if (!pet) return;
setName(pet.name);
setBirthDate(pet.birthDate || '');
setGender(pet.gender || 'Unbekannt');
setBio(pet.bio || '');
setVisibility(pet.visibility);
}, [pet]);
if (!pet) {
return (
<section className="empty-state">
<strong>Haustier nicht gefunden</strong>
<span>Bitte gehe zurück zur Übersicht.</span>
</section>
);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSuccess('');
if (!name.trim()) {
setError('Name darf nicht leer sein.');
setError('Der Name darf nicht leer sein.');
return;
}
if (!breed) {
setError('Bitte wähle eine Rasse aus.');
return;
}
const updatedPetPayload = {
id: pet.id,
const updatedPet = {
...pet,
name,
type,
breed,
bio: bio || null,
updatedAt: new Date().toISOString(),
birthDate: birthDate || null,
ageFallback: birthDate ? null : ageFallback || null,
gender,
bio,
visibility,
};
console.log('Haustier aktualisiert (Mock):', updatedPetPayload);
console.log('UPDATED PET:', updatedPet);
setSuccess('Änderungen gespeichert ✅');
setSuccess('Änderungen wurden gespeichert.');
setTimeout(() => {
router.push(`/pets/${pet.id}`);
}, 800);
}
function handleDelete() {
console.log('Haustier gelöscht (Mock):', pet.id);
// Später:
// await fetch(`/api/pets/${pet.id}`, { method: 'DELETE' })
// router.push('/pets');
setShowDeleteConfirm(false);
alert('Haustier wurde technisch gelöscht (Mock)');
}
function handleCancel() {
router.back();
}
return (
<section
style={{
maxWidth: '600px',
margin: '0 auto',
display: 'grid',
gap: '1.5rem',
}}
>
<header>
<section className="pets-page">
<header className="pets-header">
<h1>Haustier bearbeiten</h1>
<p style={{ color: '#555' }}>
Passe die Informationen deines Haustiers an.
</p>
<p>Du kannst die Angaben jederzeit korrigieren.</p>
</header>
<form
onSubmit={handleSubmit}
style={{
border: '1px solid #e5e5e5',
borderRadius: '16px',
padding: '1rem',
display: 'grid',
gap: '1rem',
backgroundColor: 'white',
}}
>
<form className="pet-form" onSubmit={handleSubmit}>
{/* Name */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Name</strong>
<input
value={name}
onChange={(e) => setName(e.target.value)}
style={{
padding: '0.7rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
/>
<div className="form-group">
<label>
Name <span className="required">*</span>
</label>
<input value={name} onChange={(e) => setName(e.target.value)} />
</div>
{/* Art */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Tier-Art</strong>
<select
value={type}
{/* Art & Rasse (read-only) */}
<div className="form-group">
<label>Tier-Art</label>
<input value={pet.type} disabled />
<small>Kann nachträglich nicht geändert werden.</small>
</div>
<div className="form-group">
<label>Rasse</label>
<input value={pet.breed} disabled />
<small>Kann nachträglich nicht geändert werden.</small>
</div>
{/* Geburtsdatum */}
<div className="form-group">
<label>Geburtsdatum</label>
<input
type="date"
value={birthDate}
onChange={(e) => {
setType(e.target.value as PetType);
setBreed('');
setBreedSearch('');
}}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
maxWidth: '220px',
}}
>
<option value="Katze">Katze</option>
<option value="Hund">Hund</option>
</select>
</div>
{/* Rasse */}
<div style={{ display: 'grid', gap: '0.5rem' }}>
<strong>Rasse</strong>
<input
placeholder="Rasse suchen …"
value={breedSearch}
onChange={(e) => setBreedSearch(e.target.value)}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
setBirthDate(e.target.value);
if (e.target.value) setAgeFallback('');
}}
/>
{birthDate && (
<small>
Aktuelles Alter: <strong>{formatPetAge(birthDate)}</strong>
</small>
)}
</div>
{/* Fallback-Alter */}
{!birthDate && (
<div className="form-group">
<label>Alter (geschätzt)</label>
<input
type="number"
min={0}
max={30}
value={ageFallback}
onChange={(e) => setAgeFallback(e.target.value)}
/>
<small>Wird verwendet, wenn kein Geburtsdatum bekannt ist.</small>
</div>
)}
{/* Geschlecht */}
<div className="form-group">
<label>Geschlecht</label>
<select
value={breed}
onChange={(e) => setBreed(e.target.value)}
size={Math.min(filteredBreeds.length, 6)}
style={{
padding: '0.6rem',
borderRadius: '10px',
border: '1px solid #ccc',
}}
value={gender}
onChange={(e) =>
setGender(e.target.value as 'Männlich' | 'Weiblich' | 'Unbekannt')
}
>
{filteredBreeds.map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
<option value="Unbekannt">Unbekannt</option>
<option value="Männlich">Männlich</option>
<option value="Weiblich">Weiblich</option>
</select>
</div>
{/* Bio */}
<div style={{ display: 'grid', gap: '0.4rem' }}>
<strong>Kurzbeschreibung</strong>
<div className="form-group">
<label>Kurzbeschreibung</label>
<textarea
rows={3}
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={3}
style={{
padding: '0.8rem',
borderRadius: '12px',
border: '1px solid #ccc',
}}
/>
</div>
{/* Sichtbarkeit */}
<div className="form-group">
<label>Sichtbarkeit</label>
<select
value={visibility}
onChange={(e) =>
setVisibility(e.target.value as 'public' | 'followers')
}
>
<option value="public">Öffentlich</option>
<option value="followers">Nur für Abonnenten</option>
</select>
</div>
{/* Meldungen */}
{error && <div style={{ background: '#f8d7da', padding: '0.7rem' }}>{error}</div>}
{success && <div style={{ background: '#d1e7dd', padding: '0.7rem' }}>{success}</div>}
{error && <div className="form-error">{error}</div>}
{success && <div className="form-success">{success}</div>}
{/* Aktionen */}
<div style={{ display: 'flex', gap: '0.8rem' }}>
<button
type="submit"
style={{
padding: '0.8rem 1.2rem',
borderRadius: '12px',
border: 'none',
backgroundColor: '#2E7D32',
color: 'white',
fontWeight: 700,
}}
>
<div className="form-actions">
<button type="submit" className="button-primary">
Änderungen speichern
</button>
<Link href={`/pets/${pet.id}`}>Abbrechen</Link>
<hr style={{ margin: '1rem 0', borderColor: '#eee' }} />
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
style={{
padding: '0.7rem 1.2rem',
borderRadius: '12px',
border: '1px solid #d32f2f',
backgroundColor: 'white',
color: '#d32f2f',
fontWeight: 700,
cursor: 'pointer',
}}
>
Haustier löschen
</button>
<button
type="button"
className="button-danger"
onClick={handleCancel}
>
Abbrechen
</button>
</div>
</form>
{showDeleteConfirm && (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<div
style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '1.2rem',
maxWidth: '420px',
width: '100%',
display: 'grid',
gap: '1rem',
}}
>
<h3>Haustier wirklich löschen?</h3>
<p style={{ color: '#555' }}>
Alle Beiträge dieses Haustiers gehen dabei verloren.
Diese Aktion kann nicht rückgängig gemacht werden. Bist du dir sicher?
</p>
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'flex-end' }}>
<button
onClick={() => setShowDeleteConfirm(false)}
style={{
padding: '0.6rem 1rem',
borderRadius: '10px',
border: '1px solid #ccc',
backgroundColor: 'white',
cursor: 'pointer',
}}
>
Abbrechen
</button>
<button
onClick={handleDelete}
style={{
padding: '0.6rem 1rem',
borderRadius: '10px',
border: 'none',
backgroundColor: '#d32f2f',
color: 'white',
fontWeight: 700,
cursor: 'pointer',
}}
>
Löschen
</button>
</div>
</div>
</div>
)}
</section>
);
}
+4
View File
@@ -35,6 +35,7 @@ const mockPets: Pet[] = [
breed: 'Bengal',
age: '2 Jahre',
owner: 'Anna',
birthDate: '2021-06-15',
},
{
id: 2,
@@ -43,6 +44,7 @@ const mockPets: Pet[] = [
breed: 'Labrador',
age: '4 Jahre',
owner: 'Markus',
birthDate: '2020-03-22',
},
];
@@ -57,6 +59,7 @@ const mockPosts: Post[] = [
createdAt: 'vor 3 Stunden',
likes: 12,
comments: 3,
},
{
id: 102,
@@ -68,6 +71,7 @@ const mockPosts: Post[] = [
createdAt: 'gestern',
likes: 7,
comments: 1,
},
];