diff --git a/app/Helpers/AgeHelper.php b/app/Helpers/AgeHelper.php new file mode 100644 index 00000000..3207f4f0 --- /dev/null +++ b/app/Helpers/AgeHelper.php @@ -0,0 +1,116 @@ += 0 ? $age : null; + } + + if (is_float($age)) { + return $age >= 0 ? (int)floor($age) : null; + } + + $text = trim((string)$age); + + if ($text === "") { + return null; + } + + if (preg_match('/^\d+(?:[.,]\d+)?$/', $text) === 1) { + return (int)floor((float)str_replace(",", ".", $text)); + } + + $normalized = strtolower($text); + $hasBelowQualifier = str_contains($normalized, "poniżej"); + $normalized = str_replace(['\t', ","], " ", $normalized); + $normalized = preg_replace('/\b(około|ok\.|okolo)\b/u', "", $normalized) ?? $normalized; + $normalized = preg_replace('/\s+/', " ", $normalized) ?? $normalized; + + $yearsVal = 0.0; + $monthsVal = 0.0; + $weeksVal = 0.0; + + if (preg_match('/(\d+(?:[.,]\d+)?)\s*(?:rok|roku|lata|lat|r\.)\b/u', $normalized, $m) === 1) { + $yearsVal = (float)str_replace(",", ".", $m[1]); + } + + if (preg_match('/(\d+(?:[.,]\d+)?)\s*(?:mies(?:\.|iąc|iąca|iące|ięcy)?|m\.)\b/u', $normalized, $m) === 1) { + $monthsVal = (float)str_replace(",", ".", $m[1]); + } + + if (preg_match('/(\d+(?:[.,]\d+)?)\s*(?:tydz(?:\.|ień|odnie|odni)?|t\.)\b/u', $normalized, $m) === 1) { + $weeksVal = (float)str_replace(",", ".", $m[1]); + } + + if ($yearsVal === 0.0 && $monthsVal === 0.0 && $weeksVal === 0.0) { + return null; + } + + $totalMonths = ($yearsVal * 12.0) + $monthsVal + round($weeksVal / 4.0); + $totalMonths = (int)floor($totalMonths); + + if ($hasBelowQualifier && $totalMonths > 0) { + --$totalMonths; + } + + return max($totalMonths, 0); + } + + public static function classifyDogAge(?int $months, int $adultFromMonths = 12, int $seniorFromMonths = 96): PetAge + { + if ($months === null || $months < 0) { + return PetAge::Unknown; + } + + if ($months < $adultFromMonths) { + return PetAge::Juvenile; + } + + if ($months >= $seniorFromMonths) { + return PetAge::Senior; + } + + return PetAge::Adult; + } + + public static function classifyDogAgeFromDbAge(string|int|float|null $age, int $adultFromMonths = 12, int $seniorFromMonths = 96): PetAge + { + $months = self::parseDbAgeToMonths($age); + + if ($months !== null) { + return self::classifyDogAge($months, $adultFromMonths, $seniorFromMonths); + } + + if ($age === null) { + return PetAge::Unknown; + } + + $t = mb_strtolower(trim((string)$age)); + + if (preg_match("/młod/u", $t) === 1) { + return PetAge::Juvenile; + } + + if (preg_match("/senior|starsz/u", $t) === 1) { + return PetAge::Senior; + } + + if (preg_match("/doros/u", $t) === 1) { + return PetAge::Adult; + } + + return PetAge::Unknown; + } +} diff --git a/app/Http/Controllers/PetController.php b/app/Http/Controllers/PetController.php index 974827e2..fcef4748 100644 --- a/app/Http/Controllers/PetController.php +++ b/app/Http/Controllers/PetController.php @@ -4,12 +4,15 @@ namespace App\Http\Controllers; +use App\Actions\FindPetsForPreferenceAction; use App\Http\Requests\PetRequest; use App\Http\Resources\PetIndexResource; +use App\Http\Resources\PetMatchResource; use App\Http\Resources\PetShowResource; use App\Models\Pet; use App\Services\TagService; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -19,18 +22,59 @@ public function __construct( private TagService $tagService, ) {} - public function index(): Response + public function index(Request $request): Response { - $pets = Pet::query()->latest()->paginate(15); + $perDog = 20; + $perCat = 20; + $perOther = 10; + + $dogs = Pet::query() + ->with(["tags", "media"]) + ->where("is_accepted", true) + ->whereRaw("LOWER(TRIM(species)) = ?", ["dog"]) + ->orderByDesc("id") + ->take($perDog) + ->get(); + + $cats = Pet::query() + ->with(["tags", "media"]) + ->where("is_accepted", true) + ->whereRaw("LOWER(TRIM(species)) = ?", ["cat"]) + ->orderByDesc("id") + ->take($perCat) + ->get(); + + $others = Pet::query() + ->with(["tags", "media"]) + ->where("is_accepted", true) + ->whereRaw("LOWER(TRIM(species)) = ?", ["other"]) + ->orderByDesc("id") + ->take($perOther) + ->get(); + + $pets = $dogs->merge($cats)->merge($others)->shuffle()->values(); return Inertia::render("Dashboard/Dashboard", [ "pets" => PetIndexResource::collection($pets), ]); } + public function matches(FindPetsForPreferenceAction $findPetsForPreference): Response + { + $user = request()->user(); + + $preference = $user->preferences()->first(); + + $pets = $findPetsForPreference->execute($preference); + + return Inertia::render("Dashboard/Dashboard", [ + "pets" => PetMatchResource::collection($pets)->resolve(), + ]); + } + public function show(Pet $pet): Response { - $pet->load("tags"); + $pet->load(["tags", "shelter.address", "media"]); return Inertia::render("Pets/Show", [ "pet" => new PetShowResource($pet), diff --git a/app/Http/Controllers/PreferenceController.php b/app/Http/Controllers/PreferenceController.php index 69bc2100..f1110f09 100644 --- a/app/Http/Controllers/PreferenceController.php +++ b/app/Http/Controllers/PreferenceController.php @@ -4,10 +4,10 @@ namespace App\Http\Controllers; -use App\Actions\FindPetsForPreferenceAction; use App\Http\Requests\PreferenceRequest; -use App\Http\Resources\PetMatchResource; +use App\Models\Pet; use App\Models\Preference; +use App\Models\Tag; use App\Services\GeocodingService; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; @@ -19,15 +19,47 @@ public function __construct( protected GeocodingService $geocodingService, ) {} - public function index(FindPetsForPreferenceAction $findPetsForPreference): Response + public function show(): Response { - $user = request()->user(); - $preference = $user->preferences()->first(); - - $pets = $findPetsForPreference->execute($preference); - - return Inertia::render("Dashboard/Dashboard", [ - "pets" => PetMatchResource::collection($pets)->resolve(), + $tags = Tag::query() + ->withCount(["pets as accepted_pets_count" => fn($q) => $q->where("is_accepted", true)]) + ->whereHas("pets", fn($q) => $q->where("is_accepted", true), ">", 1) + ->orderByDesc("accepted_pets_count") + ->orderBy("name") + ->get() + ->map(fn(Tag $tag): array => [ + "value" => $tag->name, + "label" => $tag->name, + "count" => (int)$tag->accepted_pets_count, + ]); + + $breedQuery = fn(string $species) => Pet::query() + ->where("species", $species) + ->where("is_accepted", true) + ->whereNotNull("breed") + ->where("breed", "!=", "") + ->distinct() + ->orderBy("breed") + ->pluck("breed"); + + $breeds = [ + "dog" => $breedQuery("dog"), + "cat" => $breedQuery("cat"), + "other" => $breedQuery("other"), + ]; + + $colors = Pet::query() + ->where("is_accepted", true) + ->whereNotNull("color") + ->where("color", "!=", "") + ->distinct() + ->orderBy("color") + ->pluck("color"); + + return Inertia::render("Preferences/Preferences", [ + "tags" => $tags, + "breeds" => $breeds, + "colors" => $colors, ]); } diff --git a/app/Http/Resources/PetIndexResource.php b/app/Http/Resources/PetIndexResource.php index eee0b847..a07ca4af 100644 --- a/app/Http/Resources/PetIndexResource.php +++ b/app/Http/Resources/PetIndexResource.php @@ -17,11 +17,45 @@ public function toArray($request): array return [ "id" => $pet->id, "name" => $pet->name, + // First existing media URL for cards/strips + "image_url" => (function () use ($pet) { + $mediaItems = $pet->getMedia("pet_images"); + + foreach ($mediaItems as $media) { + $path = $media->getPath(); + + if (is_string($path) && file_exists($path)) { + return $media->getUrl(); + } + } + })(), + "has_images" => (function () use ($pet) { + $mediaItems = $pet->getMedia("pet_images"); + + foreach ($mediaItems as $media) { + $path = $media->getPath(); + + if (is_string($path) && file_exists($path)) { + return true; + } + } + + return false; + })(), "species" => $pet->species, "breed" => $pet->breed, "sex" => $pet->sex, + "age" => $pet->age, + "admission_date" => $pet->admission_date?->format("Y-m-d"), + "size" => $pet->size, + "weight" => $pet->weight, + "color" => $pet->color, + "description" => $pet->description, "behavioral_notes" => $pet->behavioral_notes, "adoption_status" => $pet->adoption_status, + "shelter_city" => optional($pet->shelter?->address)->city, + "shelter_postal_code" => optional($pet->shelter?->address)->postal_code, + "tags" => $pet->tags->pluck("name")->values(), ]; } } diff --git a/app/Http/Resources/PetShowResource.php b/app/Http/Resources/PetShowResource.php index 5b1fc204..3b75cd47 100644 --- a/app/Http/Resources/PetShowResource.php +++ b/app/Http/Resources/PetShowResource.php @@ -18,6 +18,32 @@ public function toArray($request): array return [ "id" => $pet->id, "name" => $pet->name, + "photos" => (function () use ($pet) { + $mediaItems = $pet->getMedia("pet_images"); + + foreach ($mediaItems as $media) { + $path = $media->getPath(); + + if (is_string($path) && file_exists($path)) { + return [$media->getUrl()]; + } + } + + return []; + })(), + "has_images" => (function () use ($pet) { + $mediaItems = $pet->getMedia("pet_images"); + + foreach ($mediaItems as $media) { + $path = $media->getPath(); + + if (is_string($path) && file_exists($path)) { + return true; + } + } + + return false; + })(), "adoption_url" => $pet->adoption_url, "species" => $pet->species, "breed" => $pet->breed, @@ -47,6 +73,17 @@ public function toArray($request): array "quarantine_end_date" => $pet->quarantine_end_date ? $pet->quarantine_end_date->toDateString() : null, "found_location" => $pet->found_location, "adoption_status" => $pet->adoption_status, + "shelter" => $pet->shelter ? [ + "id" => $pet->shelter->id, + "name" => $pet->shelter->name, + "phone" => $pet->shelter->phone, + "email" => $pet->shelter->email, + "address" => $pet->shelter->address ? [ + "address" => $pet->shelter->address->address, + "city" => $pet->shelter->address->city, + "postal_code" => $pet->shelter->address->postal_code, + ] : null, + ] : null, "tags" => $pet->tags->map(fn(Tag $tag): array => [ "id" => $tag->id, "name" => $tag->name, diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 641391b3..9bbede38 100755 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -6,10 +6,16 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; class Tag extends Model { use HasFactory; protected $fillable = ["name"]; + + public function pets(): BelongsToMany + { + return $this->belongsToMany(Pet::class); + } } diff --git a/app/Services/PetMatcher.php b/app/Services/PetMatcher.php index d82cdfb5..077313e9 100644 --- a/app/Services/PetMatcher.php +++ b/app/Services/PetMatcher.php @@ -4,28 +4,58 @@ namespace App\Services; +use App\Enums\PetAge; +use App\Helpers\AgeHelper; + class PetMatcher { public function match(array $petData, array $preferences): float { + $normalized = []; + + foreach ($preferences as $field => $prefValues) { + if (!is_array($prefValues)) { + continue; + } + $list = array_is_list($prefValues) ? $prefValues : [$prefValues]; + $normalized[$field] = array_values(array_filter($list, static fn($p) => is_array($p) && (array_key_exists("value", $p) || array_key_exists("weight", $p)))); + } + $score = 0.0; $maxScore = 0.0; - foreach ($preferences as $field => $prefValues) { + foreach ($normalized as $field => $prefValues) { foreach ($prefValues as $pref) { $weight = (float)($pref["weight"] ?? 1); $maxScore += $weight; - if ($field === "tags" && isset($petData["tags"]) && is_array($petData["tags"])) { - $tagValues = array_map( - fn(array $tag): string|int => $tag["name"] ?? $tag["id"], - $petData["tags"], - ); + if ($field === "tags") { + $petTags = isset($petData["tags"]) && is_array($petData["tags"]) ? $petData["tags"] : []; + $tagValues = array_map(static function ($tag) { + if (is_array($tag)) { + return $tag["name"] ?? $tag["id"] ?? null; + } + + return $tag; + }, $petTags); - if (in_array($pref["value"], $tagValues, true)) { + if (array_key_exists("value", $pref) && in_array($pref["value"], $tagValues, true)) { $score += $weight; } - } elseif (isset($petData[$field]) && $petData[$field] === $pref["value"]) { + } elseif ($field === "age" && array_key_exists("value", $pref)) { + $petAgeCategory = AgeHelper::classifyDogAgeFromDbAge($petData["age"] ?? null); + $prefValue = (string)$pref["value"]; + + if ($petAgeCategory instanceof PetAge) { + if ($petAgeCategory->value === $prefValue) { + $score += $weight; + } + } else { + if ((string)$petAgeCategory === $prefValue) { + $score += $weight; + } + } + } elseif (isset($petData[$field]) && array_key_exists("value", $pref) && $petData[$field] === $pref["value"]) { $score += $weight; } } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 85151360..850c145d 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -12,6 +12,7 @@ public function run(): void { $this->call([ DemoSeeder::class, + PetsOnlySeeder::class, ]); } } diff --git a/database/seeders/PetsOnlySeeder.php b/database/seeders/PetsOnlySeeder.php new file mode 100644 index 00000000..92f4fe02 --- /dev/null +++ b/database/seeders/PetsOnlySeeder.php @@ -0,0 +1,134 @@ +getDriverName(); + + if ($driver === "pgsql") { + DB::statement("TRUNCATE TABLE pet_tag RESTART IDENTITY CASCADE"); + DB::statement("TRUNCATE TABLE pets RESTART IDENTITY CASCADE"); + } elseif ($driver === "mysql") { + DB::statement("SET FOREIGN_KEY_CHECKS=0;"); + DB::table("pet_tag")->truncate(); + DB::table("pets")->truncate(); + DB::statement("SET FOREIGN_KEY_CHECKS=1;"); + } else { + DB::table("pet_tag")->truncate(); + DB::table("pets")->truncate(); + } + + if (PetShelter::count() < 5) { + PetShelter::factory()->count(5 - PetShelter::count())->create(); + } + + $requiredTagNames = [ + "friendly", "playful", "calm", "active", "gentle", "smart", "independent", "curious", + "good with kids", "good with dogs", "good with cats", "house trained", "leash trained", "low shedding", + ]; + + foreach ($requiredTagNames as $name) { + Tag::firstOrCreate(["name" => $name]); + } + + $tags = Tag::whereIn("name", $requiredTagNames)->get(); + $shelters = PetShelter::all(); + + $curatedPets = [ + ["name" => "Buddy", "species" => "dog", "breed" => "Labrador Retriever", "sex" => "male", "age" => 24, "size" => "large", "weight" => 28.5, "color" => "yellow", "adoption_status" => "available", "tags" => ["friendly", "playful", "good with kids"]], + ["name" => "Luna", "species" => "cat", "breed" => "Siamese", "sex" => "female", "age" => 12, "size" => "small", "weight" => 3.8, "color" => "seal point", "adoption_status" => "available", "tags" => ["curious", "smart", "independent"]], + ["name" => "Max", "species" => "dog", "breed" => "German Shepherd", "sex" => "male", "age" => 36, "size" => "large", "weight" => 32.0, "color" => "black and tan", "adoption_status" => "available", "tags" => ["smart", "active", "leash trained"]], + ["name" => "Bella", "species" => "dog", "breed" => "Golden Retriever", "sex" => "female", "age" => 48, "size" => "large", "weight" => 29.0, "color" => "golden", "adoption_status" => "available", "tags" => ["gentle", "friendly", "good with kids"]], + ["name" => "Oliver", "species" => "cat", "breed" => "British Shorthair", "sex" => "male", "age" => 24, "size" => "medium", "weight" => 5.5, "color" => "blue", "adoption_status" => "available", "tags" => ["calm", "low shedding"]], + ["name" => "Milo", "species" => "cat", "breed" => "Maine Coon", "sex" => "male", "age" => 36, "size" => "large", "weight" => 7.2, "color" => "brown tabby", "adoption_status" => "available", "tags" => ["friendly", "good with kids"]], + ["name" => "Coco", "species" => "dog", "breed" => "Poodle", "sex" => "female", "age" => 60, "size" => "medium", "weight" => 18.0, "color" => "white", "adoption_status" => "available", "tags" => ["smart", "low shedding", "house trained"]], + ["name" => "Charlie", "species" => "dog", "breed" => "Beagle", "sex" => "male", "age" => 24, "size" => "medium", "weight" => 11.0, "color" => "tricolor", "adoption_status" => "available", "tags" => ["curious", "playful"]], + ["name" => "Rocky", "species" => "dog", "breed" => "Boxer", "sex" => "male", "age" => 48, "size" => "large", "weight" => 30.5, "color" => "fawn", "adoption_status" => "available", "tags" => ["active", "friendly"]], + ["name" => "Daisy", "species" => "dog", "breed" => "Bulldog", "sex" => "female", "age" => 36, "size" => "medium", "weight" => 22.0, "color" => "brindle", "adoption_status" => "available", "tags" => ["gentle", "calm"]], + ["name" => "Simba", "species" => "cat", "breed" => "Bengal", "sex" => "male", "age" => 24, "size" => "medium", "weight" => 4.5, "color" => "brown spotted", "adoption_status" => "available", "tags" => ["active", "curious"]], + ["name" => "Chloe", "species" => "cat", "breed" => "Ragdoll", "sex" => "female", "age" => 36, "size" => "large", "weight" => 6.0, "color" => "seal bicolor", "adoption_status" => "available", "tags" => ["gentle", "friendly"]], + ["name" => "Nala", "species" => "cat", "breed" => "Sphynx", "sex" => "female", "age" => 24, "size" => "medium", "weight" => 3.2, "color" => "pink", "adoption_status" => "available", "tags" => ["friendly", "curious"]], + ["name" => "Sadie", "species" => "dog", "breed" => "Dachshund", "sex" => "female", "age" => 72, "size" => "small", "weight" => 8.0, "color" => "red", "adoption_status" => "available", "tags" => ["independent", "house trained"]], + ["name" => "Zeus", "species" => "dog", "breed" => "Rottweiler", "sex" => "male", "age" => 60, "size" => "large", "weight" => 45.0, "color" => "black and tan", "adoption_status" => "available", "tags" => ["smart", "leash trained"]], + ["name" => "Lily", "species" => "dog", "breed" => "Yorkshire Terrier", "sex" => "female", "age" => 48, "size" => "small", "weight" => 3.2, "color" => "blue and tan", "adoption_status" => "available", "tags" => ["playful", "low shedding"]], + ["name" => "Leo", "species" => "cat", "breed" => "Russian Blue", "sex" => "male", "age" => 36, "size" => "medium", "weight" => 4.2, "color" => "blue", "adoption_status" => "available", "tags" => ["calm", "smart"]], + ["name" => "Misty", "species" => "cat", "breed" => "Persian", "sex" => "female", "age" => 60, "size" => "medium", "weight" => 4.0, "color" => "white", "adoption_status" => "available", "tags" => ["gentle", "low shedding"]], + ["name" => "Finn", "species" => "dog", "breed" => "Border Collie", "sex" => "male", "age" => 24, "size" => "medium", "weight" => 19.0, "color" => "black and white", "adoption_status" => "available", "tags" => ["smart", "active"]], + ["name" => "Zoe", "species" => "cat", "breed" => "Norwegian Forest", "sex" => "female", "age" => 48, "size" => "large", "weight" => 5.8, "color" => "brown tabby", "adoption_status" => "available", "tags" => ["friendly", "curious"]], + ]; + + foreach ($curatedPets as $data) { + $descBySpecies = [ + "dog" => "Lubi spacery i zabawę, łagodny w domu i towarzyski na zewnątrz.", + "cat" => "Ciekawska i spokojna, lubi wylegiwać się na słońcu i bawić wędką.", + ]; + $healthPool = ["healthy", "healthy", "healthy", "recovering"]; + $health = $healthPool[array_rand($healthPool)]; + $currentTreatment = $health === "recovering" ? "Krótka rekonwalescencja po szczepieniu, zalecany odpoczynek." : null; + $sterilized = rand(0, 1) === 1; + $vaccinated = rand(0, 1) === 1; + $hasChip = rand(0, 1) === 1; + $chip = $hasChip ? strtoupper(Str::random(12)) : null; + $dewormed = rand(0, 1) === 1; + $deflea = rand(0, 1) === 1; + $medicalTests = $health === "healthy" ? "basic: negative" : "blood: normal; xray: clear"; + $food = collect(["dry", "wet", "mixed"])->random(); + $behavioral = $data["species"] === "dog" ? "Spokojny w domu, energiczny na zewnątrz." : "Lubi drapaki i ciche otoczenie."; + $admission = now()->subDays(rand(7, 180)); + $city = collect(["Warsaw", "Krakow", "Gdansk", "Wroclaw", "Poznan"])->random(); + $adoptionUrl = "https://adopt.local/pets/" . Str::slug($data["name"] . " " . $data["breed"]); + + $pet = Pet::create([ + "name" => $data["name"], + "adoption_url" => $adoptionUrl, + "species" => $data["species"], + "breed" => $data["breed"], + "sex" => $data["sex"], + "age" => $data["age"], + "size" => $data["size"], + "weight" => $data["weight"], + "color" => $data["color"], + "sterilized" => $sterilized, + "description" => $descBySpecies[$data["species"]] ?? "Przyjazny i towarzyski.", + "health_status" => $health, + "current_treatment" => $currentTreatment, + "vaccinated" => $vaccinated, + "has_chip" => $hasChip, + "chip_number" => $chip, + "dewormed" => $dewormed, + "deflea_treated" => $deflea, + "medical_tests" => $medicalTests, + "food_type" => $food, + "attitude_to_people" => "friendly", + "attitude_to_dogs" => "friendly", + "attitude_to_cats" => "neutral", + "attitude_to_children" => "gentle", + "activity_level" => collect(["low", "medium", "high"])->random(), + "behavioral_notes" => $behavioral, + "admission_date" => $admission, + "found_location" => $city, + "adoption_status" => $data["adoption_status"], + "shelter_id" => $shelters->random()->id, + "is_accepted" => true, + ]); + + $tagIds = $tags->whereIn("name", $data["tags"])->pluck("id")->toArray(); + + if (!empty($tagIds)) { + $pet->tags()->sync($tagIds); + } + } + } +} diff --git a/eslint.config.js b/eslint.config.js index a71a506a..cd8b6716 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,20 @@ import blumilkDefault from '@blumilksoftware/eslint-config' +import globals from 'globals' export default [ ...blumilkDefault, + { + languageOptions: { + globals: { + ...globals.browser, + ...globals.es2022, + }, + }, + rules: { + 'n/no-unsupported-features/node-builtins': ['error', { + allowExperimental: true, + ignores: ['fetch', 'localStorage', 'sessionStorage', 'AbortController'], + }], + }, + }, ] diff --git a/package-lock.json b/package-lock.json index 84666bc2..1b70ccb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "dompurify": "^3.2.6", "laravel-vite-plugin": "^2.0.0", "lodash": "^4.17.21", + "pinia": "^3.0.3", "vue": "^3.5.17", "vue-easy-lightbox": "^1.19.0", "vue-i18n": "^11.1.11" @@ -30,6 +31,7 @@ "@tailwindcss/typography": "^0.5.10", "@typescript-eslint/eslint-plugin": "^8.37.0", "@vitejs/plugin-vue": "^6.0.1", + "ajv": "^6.12.6", "autoprefixer": "^10.4.21", "eslint": "^9.31.0", "eslint-config-prettier": "^10.1.5", @@ -1143,17 +1145,6 @@ "node": ">= 8" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "peer": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.29", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", @@ -2106,18 +2097,11 @@ "he": "^1.2.0" } }, - "node_modules/@vue/devtools-api": { - "version": "7.7.7", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz", - "integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==", - "dependencies": { - "@vue/devtools-kit": "^7.7.7" - } - }, "node_modules/@vue/devtools-kit": { "version": "7.7.7", "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.7.tgz", "integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==", + "license": "MIT", "dependencies": { "@vue/devtools-shared": "^7.7.7", "birpc": "^2.3.0", @@ -2132,6 +2116,7 @@ "version": "7.7.7", "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.7.tgz", "integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==", + "license": "MIT", "dependencies": { "rfdc": "^1.4.1" } @@ -2394,6 +2379,7 @@ "version": "2.5.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.5.0.tgz", "integrity": "sha512-VSWO/W6nNQdyP520F1mhf+Lc2f8pjGQOtoHHm7Ze8Go1kX7akpVIrtTa0fn+HB0QJEDVacl6aO08YE0PgXfdnQ==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/antfu" } @@ -2658,6 +2644,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-3.0.5.tgz", "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "license": "MIT", "dependencies": { "is-what": "^4.1.8" }, @@ -3681,7 +3668,8 @@ "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==" + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" }, "node_modules/ignore": { "version": "7.0.5", @@ -3814,6 +3802,7 @@ "version": "4.1.16", "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz", "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "license": "MIT", "engines": { "node": ">=12.13" }, @@ -4352,7 +4341,8 @@ "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" }, "node_modules/mkdirp": { "version": "3.0.1", @@ -4639,7 +4629,8 @@ "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==" + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -4671,6 +4662,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.3.tgz", "integrity": "sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==", + "license": "MIT", "dependencies": { "@vue/devtools-api": "^7.7.2" }, @@ -4687,6 +4679,15 @@ } } }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.7.tgz", + "integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.7" + } + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -5006,7 +5007,8 @@ "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==" + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" }, "node_modules/rollup": { "version": "4.50.1", @@ -5199,6 +5201,7 @@ "version": "14.0.1", "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -5356,6 +5359,7 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", "integrity": "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==", + "license": "MIT", "dependencies": { "copy-anything": "^3.0.2" }, diff --git a/package.json b/package.json index 0e6a2e05..3eda10c2 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dompurify": "^3.2.6", "laravel-vite-plugin": "^2.0.0", "lodash": "^4.17.21", + "pinia": "^3.0.3", "vue": "^3.5.17", "vue-easy-lightbox": "^1.19.0", "vue-i18n": "^11.1.11" @@ -34,6 +35,7 @@ "@tailwindcss/typography": "^0.5.10", "@typescript-eslint/eslint-plugin": "^8.37.0", "@vitejs/plugin-vue": "^6.0.1", + "ajv": "^6.12.6", "autoprefixer": "^10.4.21", "eslint": "^9.31.0", "eslint-config-prettier": "^10.1.5", diff --git a/resources/css/app.css b/resources/css/app.css index 562ab2bb..97fc5548 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -12,42 +12,39 @@ --color-light-soft-cream: oklch(0.9921 0.0172 99.59) /* Light soft cream */ --color-light-brown: oklch(0.8152 0.1185 76.54); /* Light brown */ } + @keyframes heartbeat { - 0% { - transform: scale(1); - opacity: 1; - } - 25% { - transform: scale(1.14); - opacity: 1; - } - 50% { - transform: scale(1); - opacity: 1; - } - 60% { - transform: scale(1.16); - opacity: 1; - } - 75% { - transform: scale(1); - opacity: 1; - } - 100% { - transform: scale(1); - opacity: 1; - } + 0% { transform: scale(1); opacity: 1; } + 25% { transform: scale(1.14); opacity: 1; } + 50% { transform: scale(1); opacity: 1; } + 60% { transform: scale(1.16); opacity: 1; } + 75% { transform: scale(1); opacity: 1; } + 100% { transform: scale(1); opacity: 1; } } + @layer utilities { - .animate-heartbeat { - animation: heartbeat 0.8s ease-out infinite; - } + .animate-heartbeat { animation: heartbeat 0.8s ease-out infinite; } } + @layer components { - .heading-xl { - @apply text-xl font-semibold text-gray-900 mb-3; - } - .heading-lg { - @apply text-3xl sm:text-4xl font-bold text-gray-900; - } + .heading-xl { @apply text-xl font-semibold text-gray-900 mb-3; } + .heading-lg { @apply text-3xl sm:text-4xl font-bold text-gray-900; } + + .disclosure { @apply overflow-hidden bg-white; } + .disclosure[open] { box-shadow: 0 6px 20px rgba(0,0,0,0.08); } + .disclosure-summary::-webkit-details-marker { display: none; } + .disclosure-summary { @apply list-none flex items-center justify-between py-3.5 px-4 border-b border-gray-200 bg-gradient-to-b from-indigo-500/5 to-transparent hover:from-indigo-500/10; } + .chevron { @apply transition-transform duration-200 ease-linear; } + .disclosure[open] .chevron { @apply rotate-180; } + + .badge-selected { @apply inline-flex items-center rounded-full border px-2 py-0.5 text-xs bg-indigo-50 text-indigo-900 border-indigo-200; } + + .tile-checkbox { @apply cursor-pointer transition-all; } + .checkbox-content { @apply flex flex-col items-center gap-1 sm:gap-2 p-3 sm:p-4 text-center min-h-[90px] h-0 sm:min-h-[100px] rounded-lg border-2 border-gray-200 bg-white transition-shadow; } + .tile-checkbox:hover .checkbox-content { @apply shadow-[0_8px_25px_rgba(0,0,0,0.15)]; } + .tile-checkbox input:checked + .checkbox-content { @apply shadow-[0_8px_25px_rgba(99,102,241,0.3)] border-indigo-500 bg-gradient-to-br from-indigo-500 to-violet-500 text-white; } + .tile-checkbox:focus-visible .checkbox-content { @apply outline outline-2 outline-offset-2 outline-indigo-500; } + .tile-checkbox:focus-visible input:checked + .checkbox-content { @apply outline outline-2 outline-offset-2 outline-white border-white; } + .icon-container { @apply flex items-center justify-center h-8 sm:h-10 w-full; } + .label-container { @apply break-words whitespace-normal leading-snug; } } diff --git a/resources/js/Components/ActionMessage.vue b/resources/js/Components/ActionMessage.vue index d999f81f..a2ce0e36 100644 --- a/resources/js/Components/ActionMessage.vue +++ b/resources/js/Components/ActionMessage.vue @@ -7,7 +7,7 @@ defineProps({ - - diff --git a/resources/js/Pages/LandingPage/LandingPage.vue b/resources/js/Pages/LandingPage/LandingPage.vue index f0f831a3..04cad66f 100644 --- a/resources/js/Pages/LandingPage/LandingPage.vue +++ b/resources/js/Pages/LandingPage/LandingPage.vue @@ -1,21 +1,30 @@ diff --git a/resources/js/Pages/Pets/Partials/PetDetails.vue b/resources/js/Pages/Pets/Partials/PetDetails.vue index da4106c7..0142bb9f 100644 --- a/resources/js/Pages/Pets/Partials/PetDetails.vue +++ b/resources/js/Pages/Pets/Partials/PetDetails.vue @@ -1,8 +1,9 @@ diff --git a/resources/js/Pages/Pets/Partials/PetGallery.vue b/resources/js/Pages/Pets/Partials/PetGallery.vue index b43cd8ca..fe2c1c84 100644 --- a/resources/js/Pages/Pets/Partials/PetGallery.vue +++ b/resources/js/Pages/Pets/Partials/PetGallery.vue @@ -14,11 +14,18 @@ const props = defineProps({ const imageUrls = computed(() => { const pet = props.pet if (!pet) return [] - if (Array.isArray(pet.photos) && pet.photos.length) return pet.photos + const originals = Array.isArray(pet.photos) ? pet.photos : [] + const previews = Array.isArray(pet.photos_preview) ? pet.photos_preview : [] + if (originals.length) return originals + if (previews.length) return previews if (pet.imageUrl) return [pet.imageUrl] return [] }) +const hasImages = computed(() => { + return imageUrls.value.length > 0 +}) + const currentImageIndex = ref(0) const hasMultipleImages = computed(() => imageUrls.value.length > 1) const isDragging = ref(false) @@ -170,18 +177,18 @@ onBeforeUnmount(() => { @touchmove="handleTouchMove" @touchend="handleTouchEnd" > -