This commit is contained in:
gpt-engineer-app[bot] 2026-01-27 10:59:48 +00:00
parent b331aa1a7d
commit 13aefc8293
14 changed files with 556 additions and 74 deletions

View File

@ -1,26 +1,28 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- TODO: Set the document title to the name of your application -->
<title>Lovable App</title>
<meta name="description" content="Lovable Generated Project" />
<meta name="author" content="Lovable" />
<title>Pawfect Match - Find Love for Your Pet</title>
<meta name="description" content="Swipe right to find the perfect companion for your furry friend!" />
<meta name="author" content="Pawfect Match" />
<!-- TODO: Update og:title to match your application name -->
<meta property="og:title" content="Lovable App" />
<meta property="og:description" content="Lovable Generated Project" />
<meta property="og:title" content="Pawfect Match - Tinder for Animals" />
<meta property="og:description" content="Swipe right to find the perfect companion for your furry friend!" />
<meta property="og:type" content="website" />
<meta property="og:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@Lovable" />
<meta name="twitter:site" content="@PawfectMatch" />
<meta name="twitter:image" content="https://lovable.dev/opengraph-image-p98pqg.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@400;500;600;700&family=Nunito:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

BIN
src/assets/pets/cat1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

BIN
src/assets/pets/cat2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

BIN
src/assets/pets/dog1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
src/assets/pets/dog2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
src/assets/pets/dog3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -0,0 +1,37 @@
import { Heart, RefreshCw } from 'lucide-react';
interface EmptyStateProps {
onReset: () => void;
likedPets: number;
}
export function EmptyState({ onReset, likedPets }: EmptyStateProps) {
return (
<div className="flex h-full flex-col items-center justify-center px-8 text-center">
<div className="animate-float mb-6">
<Heart className="h-20 w-20 fill-muted text-muted" />
</div>
<h2 className="font-display text-2xl font-bold text-foreground">
No More Pets Nearby!
</h2>
<p className="mt-3 font-body text-muted-foreground">
You've seen all the adorable pets in your area.
{likedPets > 0 && (
<span className="mt-1 block text-primary">
You matched with {likedPets} pet{likedPets > 1 ? 's' : ''}! 🎉
</span>
)}
</p>
<button
onClick={onReset}
className="mt-8 flex items-center gap-2 rounded-full bg-primary px-8 py-4 font-display font-semibold text-primary-foreground shadow-lg transition-all hover:scale-105 hover:shadow-xl"
>
<RefreshCw className="h-5 w-5" />
Start Over
</button>
</div>
);
}

22
src/components/Header.tsx Normal file
View File

@ -0,0 +1,22 @@
import { Heart, User, Settings } from 'lucide-react';
export function Header() {
return (
<header className="flex items-center justify-between px-6 py-4">
<button className="rounded-full p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<User className="h-6 w-6" />
</button>
<div className="flex items-center gap-2">
<Heart className="h-8 w-8 fill-primary text-primary" />
<h1 className="font-display text-2xl font-bold text-foreground">
Paw<span className="text-primary">fect</span>
</h1>
</div>
<button className="rounded-full p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground">
<Settings className="h-6 w-6" />
</button>
</header>
);
}

View File

@ -0,0 +1,65 @@
import { Heart, MessageCircle, X } from 'lucide-react';
import type { Pet } from '@/data/pets';
interface MatchModalProps {
pet: Pet;
onClose: () => void;
}
export function MatchModal({ pet, onClose }: MatchModalProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
<div className="animate-match-pop relative max-w-sm rounded-3xl bg-gradient-to-br from-primary via-primary to-secondary p-1 shadow-2xl">
<div className="rounded-3xl bg-card p-8 text-center">
{/* Close button */}
<button
onClick={onClose}
className="absolute right-4 top-4 rounded-full p-2 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<X className="h-5 w-5" />
</button>
{/* Hearts animation */}
<div className="mb-4 flex justify-center">
<div className="animate-float relative">
<Heart className="h-16 w-16 fill-primary text-primary" />
<Heart className="absolute -right-4 -top-2 h-8 w-8 fill-secondary text-secondary opacity-70" />
<Heart className="absolute -left-3 top-4 h-6 w-6 fill-accent text-accent opacity-60" />
</div>
</div>
<h2 className="font-display text-3xl font-bold text-primary">It's a Match!</h2>
<p className="mt-2 font-body text-muted-foreground">
You and <span className="font-semibold text-foreground">{pet.name}</span> liked each other!
</p>
{/* Pet image */}
<div className="mx-auto mt-6 h-32 w-32 overflow-hidden rounded-full border-4 border-primary shadow-lg">
<img
src={pet.image}
alt={pet.name}
className="h-full w-full object-cover"
/>
</div>
{/* Actions */}
<div className="mt-8 flex flex-col gap-3">
<button
onClick={onClose}
className="flex items-center justify-center gap-2 rounded-full bg-primary px-6 py-3 font-display font-semibold text-primary-foreground shadow-lg transition-all hover:scale-105 hover:shadow-xl"
>
<MessageCircle className="h-5 w-5" />
Send a Woof!
</button>
<button
onClick={onClose}
className="rounded-full px-6 py-3 font-body font-medium text-muted-foreground transition-colors hover:text-foreground"
>
Keep Swiping
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,159 @@
import { useState } from 'react';
import { Heart, X, MapPin, Info } from 'lucide-react';
import type { Pet } from '@/data/pets';
interface SwipeCardProps {
pet: Pet;
onSwipe: (direction: 'left' | 'right') => void;
isTop: boolean;
}
export function SwipeCard({ pet, onSwipe, isTop }: SwipeCardProps) {
const [swipeDirection, setSwipeDirection] = useState<'left' | 'right' | null>(null);
const [showInfo, setShowInfo] = useState(false);
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const handleSwipe = (direction: 'left' | 'right') => {
setSwipeDirection(direction);
setTimeout(() => onSwipe(direction), 400);
};
const handleDragStart = (clientX: number, clientY: number) => {
if (!isTop) return;
setDragStart({ x: clientX, y: clientY });
};
const handleDragMove = (clientX: number) => {
if (!dragStart || !isTop) return;
const offset = clientX - dragStart.x;
setDragOffset({ x: offset, y: 0 });
};
const handleDragEnd = () => {
if (!dragStart || !isTop) return;
if (dragOffset.x > 100) {
handleSwipe('right');
} else if (dragOffset.x < -100) {
handleSwipe('left');
}
setDragStart(null);
setDragOffset({ x: 0, y: 0 });
};
const rotation = dragOffset.x * 0.05;
const opacity = Math.max(0.5, 1 - Math.abs(dragOffset.x) / 500);
return (
<div
className={`absolute inset-0 transition-transform ${
swipeDirection === 'right'
? 'animate-swipe-right'
: swipeDirection === 'left'
? 'animate-swipe-left'
: ''
}`}
style={{
transform: dragStart
? `translateX(${dragOffset.x}px) rotate(${rotation}deg)`
: undefined,
opacity: dragStart ? opacity : 1,
zIndex: isTop ? 10 : 0,
}}
onMouseDown={(e) => handleDragStart(e.clientX, e.clientY)}
onMouseMove={(e) => handleDragMove(e.clientX)}
onMouseUp={handleDragEnd}
onMouseLeave={handleDragEnd}
onTouchStart={(e) => handleDragStart(e.touches[0].clientX, e.touches[0].clientY)}
onTouchMove={(e) => handleDragMove(e.touches[0].clientX)}
onTouchEnd={handleDragEnd}
>
<div className="relative h-full w-full overflow-hidden rounded-3xl bg-card shadow-2xl cursor-grab active:cursor-grabbing">
{/* Image */}
<img
src={pet.image}
alt={pet.name}
className="h-full w-full object-cover"
draggable={false}
/>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
{/* Like/Nope indicators */}
{dragOffset.x > 50 && (
<div className="absolute left-6 top-6 rotate-[-15deg] rounded-lg border-4 border-like px-4 py-2">
<span className="font-display text-3xl font-bold text-like">LIKE</span>
</div>
)}
{dragOffset.x < -50 && (
<div className="absolute right-6 top-6 rotate-[15deg] rounded-lg border-4 border-nope px-4 py-2">
<span className="font-display text-3xl font-bold text-nope">NOPE</span>
</div>
)}
{/* Pet info */}
<div className="absolute bottom-0 left-0 right-0 p-6 text-primary-foreground">
<div className="flex items-end justify-between">
<div className="flex-1">
<h2 className="font-display text-4xl font-bold drop-shadow-lg">
{pet.name}, {pet.age}
</h2>
<p className="mt-1 font-body text-lg opacity-90 drop-shadow">{pet.breed}</p>
<div className="mt-2 flex items-center gap-1 opacity-80">
<MapPin className="h-4 w-4" />
<span className="font-body text-sm">{pet.distance}</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setShowInfo(!showInfo);
}}
className="rounded-full bg-card/30 p-3 backdrop-blur-sm transition-all hover:bg-card/50"
>
<Info className="h-6 w-6" />
</button>
</div>
{/* Expanded info */}
{showInfo && (
<div className="mt-4 rounded-2xl bg-card/20 p-4 backdrop-blur-sm">
<p className="font-body text-sm leading-relaxed">{pet.bio}</p>
<div className="mt-3 flex flex-wrap gap-2">
{pet.interests.map((interest) => (
<span
key={interest}
className="rounded-full bg-primary/80 px-3 py-1 font-body text-xs font-medium"
>
{interest}
</span>
))}
</div>
</div>
)}
</div>
</div>
{/* Action buttons */}
{isTop && (
<div className="absolute -bottom-20 left-1/2 flex -translate-x-1/2 gap-6">
<button
onClick={() => handleSwipe('left')}
className="group flex h-16 w-16 items-center justify-center rounded-full border-2 border-nope bg-card shadow-lg transition-all hover:scale-110 hover:bg-nope"
>
<X className="h-8 w-8 text-nope transition-colors group-hover:text-nope-foreground" />
</button>
<button
onClick={() => handleSwipe('right')}
className="group flex h-16 w-16 items-center justify-center rounded-full border-2 border-like bg-card shadow-lg transition-all hover:scale-110 hover:bg-like"
>
<Heart className="h-8 w-8 text-like transition-colors group-hover:text-like-foreground" />
</button>
</div>
)}
</div>
);
}

75
src/data/pets.ts Normal file
View File

@ -0,0 +1,75 @@
import dog1 from '@/assets/pets/dog1.jpg';
import cat1 from '@/assets/pets/cat1.jpg';
import dog2 from '@/assets/pets/dog2.jpg';
import cat2 from '@/assets/pets/cat2.jpg';
import dog3 from '@/assets/pets/dog3.jpg';
export interface Pet {
id: string;
name: string;
age: number;
breed: string;
type: 'dog' | 'cat';
bio: string;
image: string;
distance: string;
interests: string[];
}
export const pets: Pet[] = [
{
id: '1',
name: 'Buddy',
age: 2,
breed: 'Golden Retriever',
type: 'dog',
bio: 'Love long walks, belly rubs, and making new furry friends! 🐾 Looking for my pawfect match to explore the park together.',
image: dog1,
distance: '2 miles away',
interests: ['Fetch', 'Swimming', 'Cuddles', 'Treats'],
},
{
id: '2',
name: 'Whiskers',
age: 3,
breed: 'Orange Tabby',
type: 'cat',
bio: 'Professional napper and window watcher. Seeking a sophisticated feline companion who appreciates the finer things in life. 😺',
image: cat1,
distance: '1 mile away',
interests: ['Napping', 'Bird Watching', 'Cardboard Boxes', 'Laser Dots'],
},
{
id: '3',
name: 'Pierre',
age: 4,
breed: 'French Bulldog',
type: 'dog',
bio: 'Oui oui! I am a distinguished gentlepup with a passion for snoring and being adorable. Bonus points if you love snuggling! 🥖',
image: dog2,
distance: '3 miles away',
interests: ['Snoring', 'Snuggling', 'Short Walks', 'Fashion'],
},
{
id: '4',
name: 'Luna',
age: 2,
breed: 'Persian',
type: 'cat',
bio: 'Elegant, fluffy, and fabulous. I deserve a companion as majestic as myself. Grooming sessions are my love language. ✨',
image: cat2,
distance: '4 miles away',
interests: ['Grooming', 'Sunbathing', 'Being Admired', 'Tuna'],
},
{
id: '5',
name: 'Max',
age: 1,
breed: 'Beagle',
type: 'dog',
bio: 'Adventure seeker with the best nose in town! Always down for a good sniff and a treat. Will howl for you! 🎵',
image: dog3,
distance: '1.5 miles away',
interests: ['Sniffing', 'Howling', 'Exploring', 'Bacon'],
},
];

View File

@ -8,92 +8,146 @@ All colors MUST be HSL.
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--background: 30 50% 98%;
--foreground: 20 20% 20%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card-foreground: 20 20% 20%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--popover-foreground: 20 20% 20%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--primary: 10 80% 65%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--secondary: 160 40% 50%;
--secondary-foreground: 0 0% 100%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--muted: 30 30% 92%;
--muted-foreground: 20 10% 50%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--accent: 45 90% 65%;
--accent-foreground: 20 20% 20%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--destructive: 0 70% 55%;
--destructive-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--border: 30 20% 88%;
--input: 30 20% 88%;
--ring: 10 80% 65%;
--radius: 0.5rem;
--radius: 1rem;
--sidebar-background: 0 0% 98%;
--like: 160 60% 45%;
--like-foreground: 0 0% 100%;
--nope: 0 70% 60%;
--nope-foreground: 0 0% 100%;
--superlike: 200 80% 55%;
--superlike-foreground: 0 0% 100%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-background: 30 50% 98%;
--sidebar-foreground: 20 20% 20%;
--sidebar-primary: 10 80% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 30 30% 92%;
--sidebar-accent-foreground: 20 20% 20%;
--sidebar-border: 30 20% 88%;
--sidebar-ring: 10 80% 65%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--background: 20 15% 12%;
--foreground: 30 20% 92%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--card: 20 15% 16%;
--card-foreground: 30 20% 92%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--popover: 20 15% 16%;
--popover-foreground: 30 20% 92%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--primary: 10 80% 65%;
--primary-foreground: 0 0% 100%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--secondary: 160 40% 45%;
--secondary-foreground: 0 0% 100%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--muted: 20 15% 22%;
--muted-foreground: 30 15% 60%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--accent: 45 90% 65%;
--accent-foreground: 20 20% 15%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--destructive: 0 70% 50%;
--destructive-foreground: 0 0% 100%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--border: 20 15% 25%;
--input: 20 15% 25%;
--ring: 10 80% 65%;
--like: 160 60% 45%;
--like-foreground: 0 0% 100%;
--nope: 0 70% 60%;
--nope-foreground: 0 0% 100%;
--superlike: 200 80% 55%;
--superlike-foreground: 0 0% 100%;
--sidebar-background: 20 15% 12%;
--sidebar-foreground: 30 20% 92%;
--sidebar-primary: 10 80% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-accent: 20 15% 22%;
--sidebar-accent-foreground: 30 20% 92%;
--sidebar-border: 20 15% 25%;
--sidebar-ring: 10 80% 65%;
}
}
@keyframes swipe-right {
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
100% { transform: translateX(150%) rotate(20deg); opacity: 0; }
}
@keyframes swipe-left {
0% { transform: translateX(0) rotate(0deg); opacity: 1; }
100% { transform: translateX(-150%) rotate(-20deg); opacity: 0; }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
@keyframes pulse-heart {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
@keyframes match-pop {
0% { transform: scale(0) rotate(-10deg); opacity: 0; }
50% { transform: scale(1.1) rotate(5deg); opacity: 1; }
100% { transform: scale(1) rotate(0deg); opacity: 1; }
}
.animate-swipe-right {
animation: swipe-right 0.4s ease-out forwards;
}
.animate-swipe-left {
animation: swipe-left 0.4s ease-out forwards;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-pulse-heart {
animation: pulse-heart 0.6s ease-in-out;
}
.animate-match-pop {
animation: match-pop 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards;
}
@layer base {
* {
@apply border-border;

View File

@ -1,12 +1,64 @@
// Update this page (the content is just a fallback if you fail to update the page)
import { useState, useCallback } from 'react';
import { Header } from '@/components/Header';
import { SwipeCard } from '@/components/SwipeCard';
import { MatchModal } from '@/components/MatchModal';
import { EmptyState } from '@/components/EmptyState';
import { pets, type Pet } from '@/data/pets';
const Index = () => {
const [currentPets, setCurrentPets] = useState<Pet[]>([...pets]);
const [likedPets, setLikedPets] = useState<Pet[]>([]);
const [matchedPet, setMatchedPet] = useState<Pet | null>(null);
const handleSwipe = useCallback((direction: 'left' | 'right') => {
const currentPet = currentPets[currentPets.length - 1];
if (direction === 'right' && currentPet) {
setLikedPets((prev) => [...prev, currentPet]);
// Random chance of match (50%)
if (Math.random() > 0.5) {
setTimeout(() => setMatchedPet(currentPet), 500);
}
}
setCurrentPets((prev) => prev.slice(0, -1));
}, [currentPets]);
const handleReset = useCallback(() => {
setCurrentPets([...pets]);
setLikedPets([]);
}, []);
const handleCloseMatch = useCallback(() => {
setMatchedPet(null);
}, []);
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
</div>
<div className="flex min-h-screen flex-col bg-background font-body">
<Header />
<main className="relative flex flex-1 flex-col items-center justify-center px-4 pb-28">
{currentPets.length > 0 ? (
<div className="relative h-[70vh] w-full max-w-sm">
{/* Card stack */}
{currentPets.slice(-3).map((pet, index, arr) => (
<SwipeCard
key={pet.id}
pet={pet}
isTop={index === arr.length - 1}
onSwipe={handleSwipe}
/>
))}
</div>
) : (
<EmptyState onReset={handleReset} likedPets={likedPets.length} />
)}
</main>
{/* Match modal */}
{matchedPet && (
<MatchModal pet={matchedPet} onClose={handleCloseMatch} />
)}
</div>
);
};

View File

@ -47,6 +47,18 @@ export default {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
like: {
DEFAULT: "hsl(var(--like))",
foreground: "hsl(var(--like-foreground))",
},
nope: {
DEFAULT: "hsl(var(--nope))",
foreground: "hsl(var(--nope-foreground))",
},
superlike: {
DEFAULT: "hsl(var(--superlike))",
foreground: "hsl(var(--superlike-foreground))",
},
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
@ -58,6 +70,10 @@ export default {
ring: "hsl(var(--sidebar-ring))",
},
},
fontFamily: {
display: ['Fredoka', 'sans-serif'],
body: ['Nunito', 'sans-serif'],
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",