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:
1 parent
13f2dedf13
commit
aba22e2855
6 files changed
+226
-267
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user