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/Components/ChoiceTiles.vue b/resources/js/Components/ChoiceTiles.vue new file mode 100644 index 00000000..188ef85c --- /dev/null +++ b/resources/js/Components/ChoiceTiles.vue @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + {{ opt.labelKey ? t(opt.labelKey) : (opt.label || String(opt.value)) }} + + + + + + + + diff --git a/resources/js/Components/Header.vue b/resources/js/Components/Header.vue index 05c25b3c..cfa21494 100644 --- a/resources/js/Components/Header.vue +++ b/resources/js/Components/Header.vue @@ -32,7 +32,7 @@ const mobileMenuOpen = ref(false) - + {{ t('navigation.openMainMenu') }} @@ -82,7 +82,7 @@ const mobileMenuOpen = ref(false) - + ŁapGo diff --git a/resources/js/Components/Icons/CommonIcons.vue b/resources/js/Components/Icons/CommonIcons.vue index dd2fbf89..7917181a 100644 --- a/resources/js/Components/Icons/CommonIcons.vue +++ b/resources/js/Components/Icons/CommonIcons.vue @@ -1,5 +1,5 @@ - + + + + + {{ label }} + + + {{ summary }} + + {{ selected.length }} + + + + + + + + + {{ t('preferences.breeds.dogs') }} + + + {{ dogBreed }} + + + + {{ t('preferences.breeds.cats') }} + + + {{ catBreed }} + + + + + {{ t('preferences.placeholders.any') }} + {{ t('preferences.actions.ok') }} + + + + + diff --git a/resources/js/Components/filters/FilterLocationPopover.vue b/resources/js/Components/filters/FilterLocationPopover.vue new file mode 100644 index 00000000..c66a6078 --- /dev/null +++ b/resources/js/Components/filters/FilterLocationPopover.vue @@ -0,0 +1,192 @@ + + + + + {{ label }} + + + + + {{ t('preferences.location.suggestions') }} + + + + + {{ it.label }} + + + + + + + + + + {{ t('preferences.location.loading') }} + + {{ t('preferences.location.noResults') }} + + + + + + {{ t('preferences.location.results') }} + + {{ loc }} + + + + {{ t('preferences.location.noResultsUseEntered') }} + + + {{ t('preferences.location.recent') }} + + {{ loc }} + + + + + + + {{ t('preferences.actions.clear') }} + {{ t('preferences.location.useThis') }} + + + + + diff --git a/resources/js/Components/filters/FilterPopoverMulti.vue b/resources/js/Components/filters/FilterPopoverMulti.vue new file mode 100644 index 00000000..09631a7d --- /dev/null +++ b/resources/js/Components/filters/FilterPopoverMulti.vue @@ -0,0 +1,87 @@ + + + + + {{ label }} + + + {{ summary }} + + {{ selected.length }} + + + + + + + + + + {{ opt.labelKey ? t(opt.labelKey) : (opt.label || opt.value) }} + + + + {{ t('preferences.placeholders.any') }} + {{ t('preferences.actions.ok') }} + + + + + diff --git a/resources/js/Components/filters/FilterPopoverSingle.vue b/resources/js/Components/filters/FilterPopoverSingle.vue new file mode 100644 index 00000000..ccdf8167 --- /dev/null +++ b/resources/js/Components/filters/FilterPopoverSingle.vue @@ -0,0 +1,85 @@ + + + + + {{ label }} + + + {{ summary }} + + + + + + + + + {{ opt.labelKey ? t(opt.labelKey) : (opt.label || opt.value) }} + + + + {{ t('preferences.placeholders.any') }} + {{ t('preferences.actions.ok') }} + + + + + diff --git a/resources/js/Components/preferences/MapPreview.vue b/resources/js/Components/preferences/MapPreview.vue new file mode 100644 index 00000000..637fd3c8 --- /dev/null +++ b/resources/js/Components/preferences/MapPreview.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/resources/js/Components/preferences/PreferencesForm.vue b/resources/js/Components/preferences/PreferencesForm.vue new file mode 100644 index 00000000..6e14f949 --- /dev/null +++ b/resources/js/Components/preferences/PreferencesForm.vue @@ -0,0 +1,188 @@ + + + + + + + + + {{ t('preferences.title') }} + {{ t('preferences.subtitle') }} + + + + + + + + {{ stepNumber }} + + + + + + {{ title }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ t('preferences.actions.reset') }} + + + + {{ t('common.prev') || 'Wstecz' }} + {{ t('common.next') || 'Dalej' }} + + {{ t('preferences.actions.apply') }} + + + + + + + diff --git a/resources/js/Components/preferences/Steps/Step1Basic.vue b/resources/js/Components/preferences/Steps/Step1Basic.vue new file mode 100644 index 00000000..db31dc7c --- /dev/null +++ b/resources/js/Components/preferences/Steps/Step1Basic.vue @@ -0,0 +1,114 @@ + + + + + + moveFilterById('species')" + /> + + moveFilterById('breed')" + /> + + moveFilterById('sex')" + /> + + moveFilterById('color')" + /> + + + + {{ t('preferences.labels.age') }} + {{ t('preferences.placeholders.any') }} + + { form.ageIndex = value; moveFilterById('age') }"> + + {{ option.labelKey ? t(option.labelKey) : option.label }} + + + + + + + {{ t('preferences.labels.size') }} + {{ t('preferences.placeholders.any') }} + + { form.sizeIndex = value; moveFilterById('size') }"> + + {{ option.labelKey ? t(option.labelKey) : option.label }} + + + + + + + {{ t('preferences.labels.weightKg') }} + {{ t('preferences.placeholders.any') }} + + { form.weightState = value; moveFilterById('weight') }"> + + {{ option.labelKey ? t(option.labelKey) : option.label }} + + + + + + diff --git a/resources/js/Components/preferences/Steps/Step2Health.vue b/resources/js/Components/preferences/Steps/Step2Health.vue new file mode 100644 index 00000000..d784569c --- /dev/null +++ b/resources/js/Components/preferences/Steps/Step2Health.vue @@ -0,0 +1,109 @@ + + + + + + + moveFilterById('health')" + /> + + moveFilterById('adoption')" + /> + + + + {{ t('preferences.labels.healthChecks') }} + + + {{ healthChecksSummary }} + + {{ healthChecksCount }} + + + + + + + + + {{ t('preferences.checks.vaccinated') }} + + + {{ t('preferences.checks.sterilized') }} + + + {{ t('preferences.checks.microchipped') }} + + + {{ t('preferences.checks.dewormed') }} + + + {{ t('preferences.checks.defleaTreated') }} + + + + {{ t('preferences.placeholders.any') }} + {{ t('common.ok') }} + + + + + + + diff --git a/resources/js/Components/preferences/Steps/Step3Location.vue b/resources/js/Components/preferences/Steps/Step3Location.vue new file mode 100644 index 00000000..5f3316f4 --- /dev/null +++ b/resources/js/Components/preferences/Steps/Step3Location.vue @@ -0,0 +1,74 @@ + + + + + + + + + + {{ t('preferences.labels.radiusKm') }} + + {{ t('preferences.placeholders.any') }} + {{ opt.label }} + + + + + + {{ locationText || t('preferences.placeholders.cityOrZip') }} + + + + + + + diff --git a/resources/js/Components/preferences/Steps/Step4Attitudes.vue b/resources/js/Components/preferences/Steps/Step4Attitudes.vue new file mode 100644 index 00000000..b92930cd --- /dev/null +++ b/resources/js/Components/preferences/Steps/Step4Attitudes.vue @@ -0,0 +1,74 @@ + + + + + + + {{ t('preferences.attitudes') }} + + + + + {{ t('preferences.labels.attitudeToDogs') }} + {{ t('preferences.placeholders.any') }} + + { form.attitudeToDogs = val; props.moveFilterById('attitude-dogs') }"> + + {{ option.labelKey ? t(option.labelKey) : option.label }} + + + + + + + {{ t('preferences.labels.attitudeToCats') }} + {{ t('preferences.placeholders.any') }} + + { form.attitudeToCats = val; props.moveFilterById('attitude-cats') }"> + + {{ option.labelKey ? t(option.labelKey) : option.label }} + + + + + + + {{ t('preferences.labels.attitudeToChildren') }} + {{ t('preferences.placeholders.any') }} + + { form.attitudeToChildren = val; props.moveFilterById('attitude-children') }"> + + {{ option.labelKey ? t(option.labelKey) : option.label }} + + + + + + + {{ t('preferences.labels.attitudeToAdults') }} + {{ t('preferences.placeholders.any') }} + + { form.attitudeToAdults = val; props.moveFilterById('attitude-adults') }"> + + {{ option.labelKey ? t(option.labelKey) : option.label }} + + + + + + + diff --git a/resources/js/Components/preferences/Steps/Step5ActivityTags.vue b/resources/js/Components/preferences/Steps/Step5ActivityTags.vue new file mode 100644 index 00000000..93ed4dbb --- /dev/null +++ b/resources/js/Components/preferences/Steps/Step5ActivityTags.vue @@ -0,0 +1,143 @@ + + + + + + + {{ t('preferences.labels.activityLevel') }} + {{ t('preferences.placeholders.any') }} + + { form.activityLevel = val; props.moveFilterById('activity') }"> + + {{ option.labelKey ? t(option.labelKey) : option.label }} + + + + + + + {{ t('preferences.labels.tags') }} + + + {{ showAllTags ? t('preferences.actions.collapse') : t('preferences.actions.expand') }} + + + {{ t('preferences.actions.clear') }} + + + + + + {{ tag.label }}({{ tag.count }}) + + + + + + {{ t('preferences.summary') }} + {{ t('preferences.placeholders.noActiveFilters') }} + + + ... + + {{ badge.title }}: {{ badge.label }} + + + + + + + + + {{ t('preferences.actions.collapse') }} + + + + diff --git a/resources/js/Components/preferences/Steps/index.js b/resources/js/Components/preferences/Steps/index.js new file mode 100644 index 00000000..766215ba --- /dev/null +++ b/resources/js/Components/preferences/Steps/index.js @@ -0,0 +1,5 @@ +export { default as Step1Basic } from './Step1Basic.vue' +export { default as Step2Health } from './Step2Health.vue' +export { default as Step3Location } from './Step3Location.vue' +export { default as Step4Attitudes } from './Step4Attitudes.vue' +export { default as Step5ActivityTags } from './Step5ActivityTags.vue' diff --git a/resources/js/Components/preferences/TopFilters.vue b/resources/js/Components/preferences/TopFilters.vue new file mode 100644 index 00000000..7ce0d61d --- /dev/null +++ b/resources/js/Components/preferences/TopFilters.vue @@ -0,0 +1,6 @@ + + + + + diff --git a/resources/js/Pages/Dashboard/Dashboard.vue b/resources/js/Pages/Dashboard/Dashboard.vue index 950873b3..5dfc0d6f 100644 --- a/resources/js/Pages/Dashboard/Dashboard.vue +++ b/resources/js/Pages/Dashboard/Dashboard.vue @@ -1,11 +1,14 @@ @@ -40,12 +109,17 @@ const handleHidePetList = () => { - - + + - - diff --git a/resources/js/Pages/Dashboard/MVPSection.vue b/resources/js/Pages/Dashboard/MVPSection.vue index d7e39671..eeef2d66 100644 --- a/resources/js/Pages/Dashboard/MVPSection.vue +++ b/resources/js/Pages/Dashboard/MVPSection.vue @@ -3,28 +3,47 @@ import { useI18n } from 'vue-i18n' import { computed } from 'vue' import { Link } from '@inertiajs/vue3' import { HeartIcon, StarIcon, CalendarIcon, MapPinIcon } from '@heroicons/vue/20/solid' -import { bestMatches } from '@/data/petsData.js' -import { getPetTags } from '@/helpers/mappers' import { routes } from '@/routes' +import { parsePolishAgeToMonths, formatAge } from '@/helpers/formatters/age.ts' const { t } = useI18n() -const petData = bestMatches[0] +const props = defineProps({ + pet: { + type: Object, + required: true, + }, +}) + +const petData = props.pet +const petPersonality = computed(() => Array.isArray(petData.tags) ? petData.tags.slice(0, 6) : []) -const petTags = getPetTags() -const petPersonality = computed(() => { - if (!petData.tags || !Array.isArray(petData.tags)) return [] - return petData.tags.map(tagId => petTags[tagId]?.name).filter(Boolean) +const petTagObjects = computed(() => { + return petPersonality.value.map(tag => ({ + name: tag, + color: 'rounded-full bg-yellow-100 text-yellow-800', + })) }) +const ageMonths = computed(() => parsePolishAgeToMonths(petData.age)) +const showAge = computed(() => typeof ageMonths.value === 'number' && ageMonths.value > 0) +const formattedAge = computed(() => showAge.value ? formatAge(ageMonths.value) : '') + +const arrivalDate = computed(() => petData.admission_date || '') +const showArrival = computed(() => typeof arrivalDate.value === 'string' && arrivalDate.value.length > 0) +const shelterCity = computed(() => petData.shelter_city || '') +const shelterPostal = computed(() => petData.shelter_postal_code || '') +const showCity = computed(() => (shelterCity.value && shelterCity.value.length > 0) || (shelterPostal.value && shelterPostal.value.length > 0)) +const formattedLocation = computed(() => [shelterCity.value, shelterPostal.value].filter(Boolean).join(', ')) + const characteristics = computed(() => [ - `${t('dashboard.mvp.age')}: ${petData.age}`, + showAge.value ? `${t('dashboard.mvp.age')}: ${formattedAge.value}` : null, `${t('dashboard.mvp.breed')}: ${petData.breed}`, `${t('dashboard.mvp.status')}: ${petData.status}`, `${t('dashboard.mvp.gender')}: ${petData.gender === 'male' ? t('dashboard.mvp.male') : t('dashboard.mvp.female')}`, `${t('dashboard.mvp.health')}: ${t('dashboard.mvp.vaccinated')}`, `${t('dashboard.mvp.temperament')}: ${t('dashboard.mvp.gentle')}`, -]) +].filter(Boolean)) @@ -47,27 +66,35 @@ const characteristics = computed(() => [ {{ t('dashboard.mvp.featuredPet') }} - {{ t('dashboard.mvp.description', { breed: petData.breed, name: petData.name }) }} + {{ (petData.description && petData.description.trim()) || t('dashboard.mvp.description', { breed: petData.breed, name: petData.name }) }} - - - {{ petData.age }} - - - - {{ petData.breed }} - + + + + {{ arrivalDate }} + + + + + + {{ formattedLocation }} + + {{ t('dashboard.mvp.personalityTraits') }} - - - - {{ trait }} - - + + + {{ tag.name }} + + diff --git a/resources/js/Pages/Dashboard/PetGrid.vue b/resources/js/Pages/Dashboard/PetGrid.vue index c34e8165..d140edf4 100644 --- a/resources/js/Pages/Dashboard/PetGrid.vue +++ b/resources/js/Pages/Dashboard/PetGrid.vue @@ -2,8 +2,8 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import PetStrip from '@/Components/PetStrip.vue' -import { bestMatches, dogs, cats } from '@/data/petsData.js' -import { getPetTags, getGenderInfo } from '@/helpers/mappers' +import { getGenderInfo } from '@/helpers/mappers' +import { parsePolishAgeToMonths, formatAge } from '@/helpers/formatters/age.ts' import { Link } from '@inertiajs/vue3' import { routes } from '@/routes' @@ -16,20 +16,33 @@ const props = defineProps({ type: Object, default: null, }, + bestMatchesRest: { + type: Array, + default: () => [], + }, + dogs: { + type: Array, + default: () => [], + }, + cats: { + type: Array, + default: () => [], + }, }) const emit = defineEmits(['showPetList', 'hidePetList']) -const petTags = getPetTags() - const { t } = useI18n() -const getPetTagsForPet = (pet) => { - if (!pet.tags || !Array.isArray(pet.tags)) return [] - return pet.tags.map(tagId => petTags[tagId]).filter(Boolean) -} +const getPetTagsForPet = (pet) => Array.isArray(pet.tags) + ? pet.tags.map((t) => (typeof t === 'string' ? t : t?.name)).filter(Boolean) + : [] -const bestMatchesRest = computed(() => bestMatches.slice(1)) +const descriptionFor = (pet) => { + const desc = typeof pet.description === 'string' ? pet.description.trim() : '' + if (desc) return desc + return t('dashboard.mvp.description', { breed: pet.breed || '', name: pet.name || '' }) +} const handleShowPetList = (data) => { emit('showPetList', data) @@ -89,26 +102,27 @@ const handleHidePetList = () => { - {{ pet.age }} + {{ formatAge(parsePolishAgeToMonths(pet.age)) }} {{ pet.status }} - {{ tag.emoji }} - {{ tag.name }} + {{ tag }} {{ t('dashboard.aboutPet') }} - {{ pet.description }} + {{ descriptionFor(pet) }} {{ t('dashboard.mvp.seeMore') }} diff --git a/resources/js/Pages/LandingPage/ButtonSection.vue b/resources/js/Pages/LandingPage/ButtonSection.vue index 1f80bc4e..1c4ee23b 100644 --- a/resources/js/Pages/LandingPage/ButtonSection.vue +++ b/resources/js/Pages/LandingPage/ButtonSection.vue @@ -1,6 +1,8 @@ diff --git a/resources/js/Pages/LandingPage/ImageSection.vue b/resources/js/Pages/LandingPage/ImageSection.vue index 52c1db51..d6d8c87f 100644 --- a/resources/js/Pages/LandingPage/ImageSection.vue +++ b/resources/js/Pages/LandingPage/ImageSection.vue @@ -1,12 +1,26 @@ @@ -19,7 +33,7 @@ const animals = [...dogs, ...cats].slice(0, 6) - + @@ -34,6 +48,3 @@ const animals = [...dogs, ...cats].slice(0, 6) - - 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 @@ @@ -51,11 +70,11 @@ const medicalInfo = computed(() => { {{ props.pet.status }} @@ -87,10 +106,9 @@ const medicalInfo = computed(() => { - {{ tag.emoji }} {{ tag.name }} @@ -100,59 +118,59 @@ const medicalInfo = computed(() => { {{ t('dashboard.aboutPet') }} {{ props.pet?.description }} - + - + - {{ t('dashboard.mvp.healthStatus.title') }} - {{ t('dashboard.mvp.healthStatus.description') }} + {{ t('dashboard.mvp.healthStatus.title') }} + {{ t('dashboard.mvp.healthStatus.description') }} - + - + {{ t(getHealthStatusInfo(props.pet.health_status).label) }} - {{ t(getHealthStatusInfo(props.pet.health_status).description) }} + {{ t(getHealthStatusInfo(props.pet.health_status).description) }} - + - + - {{ t('dashboard.mvp.healthStatus.currentTreatment') }} - {{ props.pet.current_treatment }} + {{ t('dashboard.mvp.healthStatus.currentTreatment') }} + {{ props.pet.current_treatment }} - {{ t('dashboard.mvp.healthStatus.medicalInfo') }} + {{ t('dashboard.mvp.healthStatus.medicalInfo') }} - {{ t(info.label) }} - {{ t(info.description) }} + {{ t(info.label) }} + {{ t(info.description) }} @@ -160,6 +178,27 @@ const medicalInfo = computed(() => { + + + + + + {{ t('dashboard.mvp.adoptionAnnouncement') }} + {{ t('dashboard.mvp.adoptionAnnouncementDescription') }} + + + + + {{ t('dashboard.mvp.viewAdoptionPost') }} + + + + 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" > - - + - - + { - + + + + + + + {{ t('pets.gallery.noImagesTitle') }} + {{ t('pets.gallery.noImagesText') }} + + + + + { - + + + + + + {{ t('pets.gallery.noImagesTitle') }} + {{ t('pets.gallery.noImagesText') }} + + + + + @@ -229,11 +260,11 @@ onBeforeUnmount(() => { {{ t('pets.gallery.previous') }} - {{ t('pets.gallery.next') }} diff --git a/resources/js/Pages/Pets/Partials/PetLocation.vue b/resources/js/Pages/Pets/Partials/PetLocation.vue index b67b6802..2157bf3b 100644 --- a/resources/js/Pages/Pets/Partials/PetLocation.vue +++ b/resources/js/Pages/Pets/Partials/PetLocation.vue @@ -1,6 +1,27 @@ @@ -20,20 +41,8 @@ const { t } = useI18n() - {{ t('pets.location.shelterName') }} - {{ t('pets.location.address') }} - - - - - - - - - - - {{ t('pets.location.openingHoursTitle') }} - {{ t('pets.location.openingHoursWeek') }}{{ t('pets.location.openingHoursWeekend') }} + {{ shelterName }} + {{ addressText }} @@ -45,7 +54,19 @@ const { t } = useI18n() {{ t('pets.location.contactTitle') }} - {{ t('pets.location.contactPhone') }}{{ t('pets.location.contactEmail', { email: 'kontakt@przyjaznelapki.pl' }) }} + + + Nr tel: {{ props.pet.shelter.phone }} + + + {{ t('pets.location.contactPhone') }} + + + + {{ props.pet.shelter.email }} + + + @@ -57,12 +78,12 @@ const { t } = useI18n() loading="lazy" allowfullscreen referrerpolicy="no-referrer-when-downgrade" - src="https://www.google.com/maps?q=ul.+Leśna+15,+Warszawa&output=embed" + :src="mapsEmbed" /> { - const path = typeof window !== 'undefined' ? window.location.pathname : '' - const match = path.match(/\/(?:pets)(?:\/static)?\/(\d+)/) - const id = match ? Number(match[1]) : NaN - return Number.isFinite(id) ? id : null -}) - -const staticPet = computed(() => allPets.find(pet => pet.id === routeId.value) || null) - -const effectivePet = computed(() => { - if (props.pet && staticPet.value) { - return { - ...props.pet, - ...staticPet.value, - } +const displayPet = computed(() => { + const p = props.pet && typeof props.pet === 'object' && 'data' in props.pet ? props.pet.data : props.pet + if (!p) return null + const tagNames = Array.isArray(p.tags) ? p.tags.map(t => (typeof t === 'string' ? t : t?.name)).filter(Boolean) : [] + const originals = Array.isArray(p.photos) ? p.photos : [] + const previews = Array.isArray(p.photos_preview) ? p.photos_preview : [] + const photos = originals.length ? originals : previews + const imageUrl = (photos && photos.length) ? photos[0] : null + const rawStatus = p.adoption_status ?? p.status + const sexValue = String(p.sex ?? p.gender ?? '').toLowerCase() + let statusLabel = rawStatus + if (String(rawStatus).toLowerCase() === 'available') { + statusLabel = (sexValue === 'male' || sexValue === 'm') ? (t('dashboard.mvp.availablemale')) : (t('dashboard.mvp.availablefemale')) + } else if (rawStatus) { + const statusKey = String(rawStatus).toLowerCase().replaceAll(' ', '_') + const translated = t(`dashboard.mvp.statuses.${statusKey}`) + statusLabel = translated || rawStatus + } + const months = parsePolishAgeToMonths(p.age) + const showAge = typeof months === 'number' && months > 0 + return { + ...p, + photos, + tags: tagNames, + status: statusLabel, + gender: p.sex ?? p.gender, + microchipped: p.has_chip ?? p.microchipped ?? false, + imageUrl, + age: showAge ? formatAge(months) : null, } - return props.pet ?? staticPet.value }) -const similarPets = computed(() => { - const current = effectivePet.value - if (!current) return [] - const currentTags = new Set(Array.isArray(current.tags) ? current.tags : []) - const candidates = allPets.filter(pet => pet.id !== current.id) - const scored = candidates.map(pet => { - const petTags = Array.isArray(pet.tags) ? pet.tags : [] - const overlap = petTags.filter(tag => currentTags.has(tag)).length - return { pet: pet, score: overlap } - }) - const filtered = scored.filter(s => s.score > 0) - filtered.sort((a, b) => b.score - a.score) - return filtered.slice(0, 10).map(s => s.pet) -}) +const similarPets = computed(() => []) const showSimilarOverlay = ref(false) const similarListTitle = ref('') const similarList = ref([]) const onShowSimilar = ({ title, pets }) => { - similarListTitle.value = title || (t('dashboard.mvp.similarPets') || 'Podobne zwierzaki') + similarListTitle.value = title || (t('dashboard.mvp.similarPets')) similarList.value = Array.isArray(pets) ? pets : [] showSimilarOverlay.value = true } @@ -74,22 +71,22 @@ const onHideSimilar = () => { showSimilarOverlay.value = false } - + - + - + { showSimilarOverlay.value = false } - {{ similarPet.age }} + {{ formatAge(similarPet.age) }} {{ similarPet.status }} {{ getGenderInfo(similarPet.gender).symbol }} diff --git a/resources/js/Pages/Preferences/Preferences.vue b/resources/js/Pages/Preferences/Preferences.vue new file mode 100644 index 00000000..b014736d --- /dev/null +++ b/resources/js/Pages/Preferences/Preferences.vue @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/resources/js/app.ts b/resources/js/app.ts index 84bfff2c..c1105140 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -5,6 +5,7 @@ import { createInertiaApp } from '@inertiajs/vue3' import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers' import { createI18n } from 'vue-i18n' import VueEasyLightbox from 'vue-easy-lightbox' +import { createPinia } from 'pinia' const plModules = import.meta.glob>('./lang/pl/*.json', { eager: true }) const pl = Object.values(plModules).reduce((merged, mod) => { @@ -27,6 +28,7 @@ createInertiaApp({ resolve: (name: string) => resolvePageComponent(`./Pages/${name}.vue`, pages) as any, setup({ el, App, props, plugin }) { const app = createApp({ render: () => h(App, props) }) + .use(createPinia()) .use(plugin) .use(i18n) .use(VueEasyLightbox) diff --git a/resources/js/composables/useBreeds.js b/resources/js/composables/useBreeds.js new file mode 100644 index 00000000..2ac72e7d --- /dev/null +++ b/resources/js/composables/useBreeds.js @@ -0,0 +1,22 @@ +import { computed } from 'vue' +import { usePage } from '@inertiajs/vue3' + +export function useBreeds(form) { + const page = usePage() + const breeds = computed(() => page?.props?.breeds || { dog: [], cat: [], other: [] }) + + const dogBreedsAvailable = computed(() => (Array.isArray(form.value.species) && form.value.species.includes('dog')) ? (breeds.value.dog || []) : []) + const catBreedsAvailable = computed(() => (Array.isArray(form.value.species) && form.value.species.includes('cat')) ? (breeds.value.cat || []) : []) + + const breedOptions = computed(() => { + const selected = form.value.species + if (!Array.isArray(selected) || selected.length === 0) return [] + let list = [] + if (selected.includes('dog')) list = list.concat(breeds.value.dog || []) + if (selected.includes('cat')) list = list.concat(breeds.value.cat || []) + if (selected.includes('other')) list = list.concat(breeds.value.other || []) + return Array.from(new Set(list)).sort() + }) + + return { dogBreedsAvailable, catBreedsAvailable, breedOptions } +} diff --git a/resources/js/composables/useFormState.js b/resources/js/composables/useFormState.js new file mode 100644 index 00000000..bf7e9c02 --- /dev/null +++ b/resources/js/composables/useFormState.js @@ -0,0 +1,60 @@ +import { ref } from 'vue' + +export function useFormState() { + const form = ref({ + species: [], + breed: [], + sex: '', + ageIndex: [], + sizeIndex: [], + weightState: [], + color: [], + healthStatus: [], + vaccinated: false, + sterilized: false, + microchipped: false, + dewormed: false, + defleaTreated: false, + attitudeToDogs: null, + attitudeToCats: null, + attitudeToChildren: null, + attitudeToAdults: null, + activityLevel: null, + adoptionStatus: [], + tags: [], + location: '', + radiusKm: null, + }) + + function resetForm() { + form.value = { + species: [], + breed: [], + sex: '', + ageIndex: [], + sizeIndex: [], + weightState: [], + color: [], + healthStatus: [], + vaccinated: false, + sterilized: false, + microchipped: false, + dewormed: false, + defleaTreated: false, + attitudeToDogs: null, + attitudeToCats: null, + attitudeToChildren: null, + attitudeToAdults: null, + activityLevel: null, + adoptionStatus: [], + tags: [], + location: '', + radiusKm: null, + } + } + + function applyPreferences() { + } + + return { form, resetForm, applyPreferences } +} diff --git a/resources/js/composables/useLocation.js b/resources/js/composables/useLocation.js new file mode 100644 index 00000000..3af84434 --- /dev/null +++ b/resources/js/composables/useLocation.js @@ -0,0 +1,69 @@ +import { ref, computed } from 'vue' + +export function useLocation(form) { + const locationOpen = ref(false) + const recentLocations = ref([]) + + function loadRecentLocations() { + if (typeof localStorage !== 'undefined') { + try { + const raw = localStorage.getItem('recentLocations') + if (raw) recentLocations.value = JSON.parse(raw) + } catch (error) { + return[] + } + } + } + function saveRecentLocations() { + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem('recentLocations', JSON.stringify(recentLocations.value.slice(0, 10))) + } catch (error) { + return[] + } + } + } + + function normalized(searchString) { return searchString.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '') } + const filteredLocations = computed(() => { + const query = (form.value.location || '').trim() + if (!query) return [] + const base = Array.from(new Set([...recentLocations.value])) + return base.filter(location => normalized(location).includes(normalized(query))).slice(0, 20) + }) + + function selectLocation(location) { + form.value.location = location + if (!form.value.radiusKm || form.value.radiusKm <= 0) { + form.value.radiusKm = 5 + } + const locationIndex = recentLocations.value.indexOf(location) + if (locationIndex !== -1) recentLocations.value.splice(locationIndex, 1) + recentLocations.value.unshift(location) + saveRecentLocations() + locationOpen.value = false + } + + function clearLocation() { + form.value.location = '' + locationOpen.value = false + } + + const radiusOptions = [ + { value: 5, label: '5 km' }, + { value: 10, label: '10 km' }, + { value: 15, label: '15 km' }, + { value: 20, label: '20 km' }, + { value: 25, label: '25 km' }, + { value: 30, label: '30 km' }, + { value: 35, label: '35 km' }, + { value: 40, label: '40 km' }, + { value: 45, label: '45 km' }, + { value: 50, label: '50 km' }, + { value: 75, label: '75 km' }, + { value: 100, label: '100 km' }, + { value: 200, label: '200 km' }, + ] + + return { locationOpen, recentLocations, filteredLocations, selectLocation, clearLocation , loadRecentLocations, radiusOptions } +} diff --git a/resources/js/composables/usePreferencesSummary.js b/resources/js/composables/usePreferencesSummary.js new file mode 100644 index 00000000..76bdc33f --- /dev/null +++ b/resources/js/composables/usePreferencesSummary.js @@ -0,0 +1,140 @@ +import { computed } from 'vue' +import { useI18n } from 'vue-i18n' +import { speciesOptions, sexOptions, colorOptions, healthOptions, adoptionOptions } from '@/helpers/preferencesConfig' + +export function usePreferencesSummary(form, selectorConfigs, tagOptions) { + const { t } = useI18n() + + const fieldConfigs = { + species: { options: speciesOptions, labelKey: 'preferences.labels.species', isArray: true }, + breed: { options: null, labelKey: 'preferences.labels.breed', isArray: true, customMapper: (val) => ({ value: val, label: val }) }, + sex: { options: sexOptions, labelKey: 'preferences.labels.sex', isArray: false }, + color: { options: colorOptions, labelKey: 'preferences.labels.color', isArray: true }, + ageIndex: { options: selectorConfigs.age.options, labelKey: 'preferences.labels.age', isArray: true }, + sizeIndex: { options: selectorConfigs.size.options, labelKey: 'preferences.labels.size', isArray: true }, + weightState: { options: selectorConfigs.weight.options, labelKey: 'preferences.labels.weightKg', isArray: true }, + healthStatus: { options: healthOptions, labelKey: 'preferences.labels.healthStatus', isArray: true }, + adoptionStatus: { options: adoptionOptions, labelKey: 'preferences.labels.adoptionStatus', isArray: true }, + attitudeToDogs: { options: selectorConfigs.attitudes.options, labelKey: 'preferences.labels.attitudeToDogs', isArray: false }, + attitudeToCats: { options: selectorConfigs.attitudes.options, labelKey: 'preferences.labels.attitudeToCats', isArray: false }, + attitudeToChildren: { options: selectorConfigs.attitudes.options, labelKey: 'preferences.labels.attitudeToChildren', isArray: false }, + attitudeToAdults: { options: selectorConfigs.attitudes.options, labelKey: 'preferences.labels.attitudeToAdults', isArray: false }, + activityLevel: { options: selectorConfigs.activity.options, labelKey: 'preferences.labels.activityLevel', isArray: false }, + } + + const sectionToFilterMap = { + species: 'species', + breed: 'breed', + sex: 'sex', + color: 'color', + ageIndex: 'age', + sizeIndex: 'size', + weightState: 'weight', + location: 'location', + healthStatus: 'health', + adoptionStatus: 'adoption', + attitudeToDogs: 'attitude-dogs', + attitudeToCats: 'attitude-cats', + attitudeToChildren: 'attitude-children', + attitudeToAdults: 'attitude-adults', + activityLevel: 'activity', + tags: 'tags', + healthChecks: 'health-checks', + } + + function mapByOptionsWithValues(values, options, tFn) { + if (values === null || values === undefined || values === '') return [] + const arr = Array.isArray(values) ? values : [values] + const labelMap = new Map(options.map(o => [o.value, o.labelKey ? tFn(o.labelKey) : (o.label || String(o.value))])) + return arr + .filter(val => val !== null && val !== undefined && String(val) !== '') + .map(val => ({ value: val, label: labelMap.get(val) ?? String(val) })) + } + + const summary = computed(() => { + const formData = form.value || {} + const sections = [] + + Object.entries(fieldConfigs).forEach(([fieldKey, config]) => { + const value = formData[fieldKey] + if (!value || (Array.isArray(value) && value.length === 0)) return + + let items = [] + + if (config.customMapper) { + items = Array.isArray(value) ? value.map(config.customMapper) : [config.customMapper(value)] + } else if (config.options) { + items = mapByOptionsWithValues(value, config.options, (key) => t(key)) + } + + if (items.length > 0) { + sections.push({ + key: fieldKey, + title: t(config.labelKey), + items, + }) + } + }) + + if (formData.location) { + const radius = formData.radiusKm ? `±${formData.radiusKm} km` : '' + sections.push({ + key: 'location', + title: t('preferences.labels.location'), + items: [{ value: 'location', label: radius ? `${formData.location} (${radius})` : formData.location }], + }) + } + + const checks = [] + const healthCheckFields = ['vaccinated', 'sterilized', 'microchipped', 'dewormed', 'defleaTreated'] + healthCheckFields.forEach(field => { + if (formData[field]) { + checks.push({ value: field, label: t(`preferences.checks.${field}`) }) + } + }) + if (checks.length) { + sections.push({ key: 'healthChecks', title: t('preferences.labels.healthChecks'), items: checks }) + } + + if (Array.isArray(formData.tags) && formData.tags.length) { + const tagMap = new Map((Array.isArray(tagOptions) ? tagOptions : []).map(o => [o.value, o.label])) + const tagItems = formData.tags.map(v => ({ value: v, label: tagMap.get(v) || String(v) })) + sections.push({ key: 'tags', title: t('preferences.labels.tags'), items: tagItems }) + } + + return sections + }) + + function clearItem(sectionKey, value, moveFilterById) { + const formData = form.value + + if (sectionKey === 'location') { + formData.location = '' + } else if (sectionKey === 'healthChecks') { + if (value) formData[value] = false + } else if (sectionKey === 'tags') { + formData.tags = (formData.tags || []).filter(v => v !== value) + } else { + const config = fieldConfigs[sectionKey] + if (config) { + if (config.isArray) { + formData[sectionKey] = (formData[sectionKey] || []).filter(v => v !== value) + } else { + formData[sectionKey] = formData[sectionKey] === value ? null : formData[sectionKey] + } + } + } + + const filterKey = sectionToFilterMap[sectionKey] + if (filterKey && moveFilterById) { + moveFilterById(filterKey) + } + } + + return { + summary, + clearItem, + fieldConfigs, + sectionToFilterMap, + } +} diff --git a/resources/js/composables/useScroll.js b/resources/js/composables/useScroll.js new file mode 100644 index 00000000..bfc9a123 --- /dev/null +++ b/resources/js/composables/useScroll.js @@ -0,0 +1,9 @@ +import { ref, onMounted, onUnmounted } from 'vue' + +export function useScroll() { + const showScrollToTop = ref(false) + function handleScroll() { showScrollToTop.value = window.scrollY > 300 } + onMounted(() => window.addEventListener('scroll', handleScroll)) + onUnmounted(() => window.removeEventListener('scroll', handleScroll)) + return { showScrollToTop } +} diff --git a/resources/js/composables/useTopFilters.js b/resources/js/composables/useTopFilters.js new file mode 100644 index 00000000..593a40a9 --- /dev/null +++ b/resources/js/composables/useTopFilters.js @@ -0,0 +1,4 @@ +export function useTopFilters() { + function moveFilterById() {} + return { moveFilterById } +} diff --git a/resources/js/data/petTagsConfig.js b/resources/js/data/petTagsConfig.js deleted file mode 100644 index 00042c3e..00000000 --- a/resources/js/data/petTagsConfig.js +++ /dev/null @@ -1,18 +0,0 @@ -import { useI18n } from 'vue-i18n' - -export function getPetTags() { - const { t } = useI18n() - - return { - friendly: { name: t('landing.petTags.friendly'), emoji: '😊', color: 'bg-white text-green-600 border-green-300' }, - gentle: { name: t('landing.petTags.gentle'), emoji: '🥰', color: 'bg-white text-pink-600 border-pink-300' }, - energetic: { name: t('landing.petTags.energetic'), emoji: '⚡', color: 'bg-white text-yellow-600 border-yellow-300' }, - playful: { name: t('landing.petTags.playful'), emoji: '🎾', color: 'bg-white text-blue-600 border-blue-300' }, - calm: { name: t('landing.petTags.calm'), emoji: '😌', color: 'bg-white text-indigo-600 border-indigo-300' }, - loyal: { name: t('landing.petTags.loyal'), emoji: '❤️', color: 'bg-white text-red-600 border-red-300' }, - smart: { name: t('landing.petTags.smart'), emoji: '🧠', color: 'bg-white text-purple-600 border-purple-300' }, - protective: { name: t('landing.petTags.protective'), emoji: '🛡️', color: 'bg-white text-gray-600 border-gray-300' }, - social: { name: t('landing.petTags.social'), emoji: '👥', color: 'bg-white text-teal-600 border-teal-300' }, - active: { name: t('landing.petTags.active'), emoji: '🏃', color: 'bg-white text-orange-600 border-orange-300' }, - } -} diff --git a/resources/js/data/petsData.js b/resources/js/data/petsData.js index 8c23da80..8323f216 100644 --- a/resources/js/data/petsData.js +++ b/resources/js/data/petsData.js @@ -1,242 +1,55 @@ +export const dogs = [ + { breed: 'Labrador Retriever' }, + { breed: 'German Shepherd' }, + { breed: 'Golden Retriever' }, + { breed: 'Bulldog' }, + { breed: 'Poodle' }, + { breed: 'Beagle' }, + { breed: 'Rottweiler' }, + { breed: 'Yorkshire Terrier' }, + { breed: 'Dachshund' }, + { breed: 'Boxer' }, +] + +export const cats = [ + { breed: 'British Shorthair' }, + { breed: 'Siamese' }, + { breed: 'Maine Coon' }, + { breed: 'Sphynx' }, + { breed: 'Persian' }, + { breed: 'Ragdoll' }, + { breed: 'Bengal' }, + { breed: 'Russian Blue' }, + { breed: 'Norwegian Forest' }, + { breed: 'Scottish Fold' }, +] + export const bestMatches = [ { id: 1, - name: 'Max', - breed: 'Kot', - age: '2 lata', - status: 'Dostępny', - gender: 'male', - vaccinated: true, - sterilized: true, - dewormed: true, - healthy: true, - health_status: 'healthy', - microchipped: true, - description: 'Energiczny kocur, uwielbia eksplorować i wspinaczki. Jest bardzo aktywny i lubi się bawić. Jego ulubionym zajęciem jest gra w piłkę. Jest bardzo inteligentny i lubi się uczyć nowych rzeczy. Jest bardzo miły i lubi się przytulać.', - imageUrl: 'https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - photos: [ - 'https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - 'https://images.unsplash.com/photo-1544568100-847a948585b9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - 'https://images.unsplash.com/photo-1574158622682-e40e69881006?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - ], - tags: ['energetic', 'playful', 'smart'], - }, - { - id: 2, - name: 'Sophie', - breed: 'Kotka', - age: '3 lata', - status: 'Dostępna', - gender: 'female', - vaccinated: true, - sterilized: false, - dewormed: true, - healthy: true, - health_status: 'healthy', - microchipped: false, - description: 'Łagodna kotka, uwielbia się przytulać i być czesana', - imageUrl: 'https://images.unsplash.com/photo-1513360371669-4adf3dd7dff8?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - tags: ['gentle', 'calm', 'social'], - }, - { - id: 3, name: 'Buddy', - breed: 'Golden Retriever', - age: '3 lata', - status: 'Dostępny', + breed: 'Labrador Retriever', + age: '2 years', + status: 'Available', gender: 'male', - vaccinated: false, - sterilized: true, - dewormed: true, - healthy: false, - health_status: 'sick', - microchipped: true, - description: 'Przyjazny pies, uwielbia aportowanie i pływanie', - imageUrl: 'https://images.unsplash.com/photo-1552053831-71594a27632d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - photos: [ - 'https://images.unsplash.com/photo-1552053831-71594a27632d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - 'https://images.unsplash.com/photo-1601758228041-f3b2795255f1?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - 'https://images.unsplash.com/photo-1505628346881-b72b27e84b2a?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - 'https://images.unsplash.com/photo-1568393691622-c7ba131d63b4?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - ], - tags: ['friendly', 'loyal', 'playful', 'active'], + imageUrl: '/Images/cat-dog.png', + description: 'Friendly and energetic companion.', + tags: ['friendly', 'playful', 'active'], }, { - id: 4, + id: 2, name: 'Luna', - breed: 'Husky', - age: '2 lata', - status: 'Dostępna', - gender: 'female', - vaccinated: true, - sterilized: true, - dewormed: true, - healthy: true, - health_status: 'healthy', - microchipped: true, - description: 'Energiczna suczka, uwielbia biegać i zabawy na śniegu', - imageUrl: 'https://images.unsplash.com/photo-1547407139-3c921a66005c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - tags: ['energetic', 'active', 'social', 'playful'], - }, - { - id: 5, - name: 'Bella', - breed: 'York', - age: '2 lata', - status: 'Dostępny', + breed: 'Siamese', + age: '1 year', + status: 'Available', gender: 'female', - vaccinated: true, - sterilized: true, - dewormed: true, - healthy: true, - health_status: 'healthy', - microchipped: true, - description: 'Energiczna suczka, uwielbia biegać i zabawy na śniegu', - imageUrl: 'https://images.unsplash.com/photo-1547407139-3c921a66005c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - tags: ['energetic', 'active', 'social', 'playful'], - }, - -] - -export const dogs = [ - { - id: 6, - name: 'Milo', - breed: 'Beagle', - age: '1.5 roku', - status: 'Dostępny', - gender: 'male', - vaccinated: true, - sterilized: true, - dewormed: false, - healthy: true, - health_status: 'healthy', - microchipped: true, - description: 'Ciekawski piesek, uwielbia tropić i zabawy w chowanego', - imageUrl: 'https://images.unsplash.com/photo-1507146426996-ef05306b995a?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - tags: ['playful', 'smart', 'active', 'social'], - }, - { - id: 7, - name: 'Charlie', - breed: 'Labrador', - age: '2.5 roku', - status: 'Dostępny', - gender: 'male', - vaccinated: true, - sterilized: true, - dewormed: true, - healthy: false, - health_status: 'sick', - microchipped: false, - description: 'Przyjazny labrador, uwielbia wodę i zabawy z piłką', - imageUrl: 'https://images.unsplash.com/photo-1552053831-71594a27632d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - tags: ['friendly', 'playful', 'active', 'loyal'], - }, - { - id: 8, - name: 'Duke', - breed: 'Bernardyn', - age: '3.5 roku', - status: 'Dostępny', - gender: 'male', - vaccinated: true, - sterilized: true, - dewormed: true, - healthy: true, - health_status: 'healthy', - microchipped: true, - description: 'Łagodny olbrzym, uwielbia dzieci i długie spacery', - imageUrl: 'https://images.unsplash.com/photo-1587300003388-59208cc962cb?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - tags: ['gentle', 'calm', 'protective', 'social'], - }, - { - id: 9, - name: 'Rex', - breed: 'Owczarek Australijski', - age: '2 lata', - status: 'Dostępny', - gender: 'male', - vaccinated: true, - sterilized: true, - dewormed: true, - healthy: true, - health_status: 'healthy', - microchipped: true, - description: 'Inteligentny pies, uwielbia agility i zabawy z frisbee', - imageUrl: 'https://images.unsplash.com/photo-1552053831-71594a27632d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - tags: ['smart', 'energetic', 'active', 'loyal'], + imageUrl: '/Images/cat-dog.png', + description: 'Gentle and smart.', + tags: ['gentle', 'smart', 'calm'], }, ] -export const cats = [ - { - id: 11, - name: 'Bella', - breed: 'Perska', - age: '4 lata', - status: 'Dostępna', - gender: 'female', - vaccinated: true, - sterilized: true, - dewormed: true, - healthy: false, - health_status: 'recovering', - microchipped: true, - description: 'Dostojna kotka, uwielbia spokój i delikatne głaskanie', - imageUrl: 'https://images.unsplash.com/photo-1518791841217-8f162f1e1131?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - tags: ['calm', 'gentle', 'social'], - }, - { - id: 12, - name: 'Nala', - breed: 'Syjamka', - age: '1 rok', - status: 'Dostępna', - gender: 'female', - vaccinated: false, - sterilized: false, - dewormed: true, - healthy: true, - health_status: 'healthy', - microchipped: true, - description: 'Inteligentna kotka, uwielbia wspinaczki i interaktywne zabawki', - imageUrl: 'https://images.unsplash.com/photo-1513360371669-4adf3dd7dff8?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - tags: ['smart', 'energetic', 'playful'], - }, - { - id: 13, - name: 'Oliver', - breed: 'Brytyjski Krótkowłosy', - age: '3 lata', - status: 'Dostępny', - gender: 'male', - vaccinated: true, - sterilized: true, - dewormed: true, - healthy: true, - health_status: 'healthy', - microchipped: true, - description: 'Spokojny kocur, uwielbia drzemki i delikatne głaskanie', - imageUrl: 'https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - tags: ['calm', 'gentle', 'social'], - }, - { - id: 14, - name: 'Misty', - breed: 'Maine Coon', - age: '2.5 roku', - status: 'Dostępna', - gender: 'female', - vaccinated: true, - sterilized: true, - dewormed: true, - healthy: true, - health_status: 'healthy', - microchipped: true, - description: 'Duża kotka, uwielbia polowania i zabawy z piórkami', - imageUrl: 'https://images.unsplash.com/photo-1513360371669-4adf3dd7dff8?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=3270&q=80', - tags: ['playful', 'energetic', 'smart'], - }, - -] +export const samplePetImages = Array.from({ length: 100 }, (_, i) => { + const id = i + 1 + return { id, imageUrl: `https://placedog.net/500?id=${id}` } +}) diff --git a/resources/js/helpers/formatters/age.ts b/resources/js/helpers/formatters/age.ts new file mode 100644 index 00000000..f018ffab --- /dev/null +++ b/resources/js/helpers/formatters/age.ts @@ -0,0 +1,96 @@ +export const formatAge = (age: number | string | null | undefined): string => { + let months = Number(age) + if (!Number.isFinite(months) || months < 0) { + const parsed = parsePolishAgeToMonths(age) + if (parsed === null) return String(age ?? '') + months = parsed + } + + const formatYearsPl = (years: number) => { + if (years === 1) return '1 rok' + if (years % 10 >= 2 && years % 10 <= 4 && (years % 100 < 10 || years % 100 >= 20)) return `${years} lata` + return `${years} lat` + } + + if (months < 12) { + return `${months} mies.` + } + + const years = Math.floor(months / 12) + const rem = months % 12 + if (rem === 0) return formatYearsPl(years) + return `${formatYearsPl(years)} ${rem} mies.` +} + +export type DogAgeCategory = 'young' | 'adult' | 'senior' | 'unknown' + +export const classifyDogAgeCategory = ( + age: number | string | null | undefined, + options?: { adultFromMonths?: number; seniorFromMonths?: number } +): DogAgeCategory => { + const months = Number(age) + if (!Number.isFinite(months) || months < 0) return 'unknown' + + const adultFrom = options?.adultFromMonths ?? 12 + const seniorFrom = options?.seniorFromMonths ?? 96 + + if (months < adultFrom) return 'young' + if (months >= seniorFrom) return 'senior' + return 'adult' +} + +export const classifyDogAgeLabelPl = ( + age: number | string | null | undefined, + options?: { adultFromMonths?: number; seniorFromMonths?: number } +): string => { + const category = classifyDogAgeCategory(age, options) + if (category === 'young') return 'młody' + if (category === 'adult') return 'dorosły' + if (category === 'senior') return 'stary' + return 'nieznany' +} + +export const parsePolishAgeToMonths = ( + ageText: string | number | null | undefined +): number | null => { + if (ageText === null || ageText === undefined) return null + + if (typeof ageText === 'number') { + return Number.isFinite(ageText) && ageText >= 0 ? Math.floor(ageText) : null + } + + const trimmed = String(ageText).trim() + if (trimmed === '') return null + + const direct = Number(trimmed) + if (Number.isFinite(direct) && direct >= 0) return Math.floor(direct) + + const normalized = trimmed + .toLowerCase() + .replaceAll(',', ' ') + .replaceAll('\t', ' ') + .replace(/\s+/g, ' ') + .trim() + + const yearsMatch = normalized.match(/(\d+)\s*(?:rok|lata|lat|r\.?)(?:\b|\s)/i) + const monthsMatch = normalized.match(/(\d+)\s*(?:mies(?:\.|iąc(?:e|y)?|iące|ięcy)?|m\.?)(?:\b|\s|$)/i) + + let years = 0 + let months = 0 + + if (yearsMatch) years = Number(yearsMatch[1]) + if (monthsMatch) months = Number(monthsMatch[1]) + + if (!yearsMatch && !monthsMatch) return null + + return years * 12 + months +} + +export const classifyDogAgeFromPolishText = ( + ageText: string | number | null | undefined, + options?: { adultFromMonths?: number; seniorFromMonths?: number } +): DogAgeCategory => { + const months = parsePolishAgeToMonths(ageText) + if (months === null) return 'unknown' + return classifyDogAgeCategory(months, options) +} diff --git a/resources/js/helpers/mappers.js b/resources/js/helpers/mappers.js new file mode 100644 index 00000000..53ed1bae --- /dev/null +++ b/resources/js/helpers/mappers.js @@ -0,0 +1,4 @@ +export { getGenderInfo } from './mappers/genderMapper.ts' +export { getAvailableMedicalInfo, getHealthStatusInfo } from './mappers/medicalInfoMapper.ts' +export { getStatusInfo } from './mappers/statusMapper.ts' +export { getPetCharacteristics } from './mappers/characteristicsMapper.ts' diff --git a/resources/js/helpers/mappers/index.ts b/resources/js/helpers/mappers/index.ts index d0bed732..ed3d622c 100644 --- a/resources/js/helpers/mappers/index.ts +++ b/resources/js/helpers/mappers/index.ts @@ -1,6 +1,5 @@ export * from './types'; export * from './genderMapper'; -export * from './petTagsConfig'; export * from './characteristicsMapper'; export * from './medicalInfoMapper'; export * from './statusMapper'; diff --git a/resources/js/helpers/mappers/petTagsConfig.ts b/resources/js/helpers/mappers/petTagsConfig.ts deleted file mode 100644 index cf406ca3..00000000 --- a/resources/js/helpers/mappers/petTagsConfig.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { useI18n } from 'vue-i18n'; -import type { PetTagsConfig, PetTagInfo } from './types'; - -export function getPetTags(): PetTagsConfig { - const { t } = useI18n(); - - return { - friendly: { - name: t('landing.petTags.friendly'), - emoji: '😊', - color: 'bg-white text-green-600 border-green-300' - }, - gentle: { - name: t('landing.petTags.gentle'), - emoji: '🥰', - color: 'bg-white text-pink-600 border-pink-300' - }, - energetic: { - name: t('landing.petTags.energetic'), - emoji: '⚡', - color: 'bg-white text-yellow-600 border-yellow-300' - }, - playful: { - name: t('landing.petTags.playful'), - emoji: '🎾', - color: 'bg-white text-blue-600 border-blue-300' - }, - calm: { - name: t('landing.petTags.calm'), - emoji: '😌', - color: 'bg-white text-indigo-600 border-indigo-300' - }, - loyal: { - name: t('landing.petTags.loyal'), - emoji: '❤️', - color: 'bg-white text-red-600 border-red-300' - }, - smart: { - name: t('landing.petTags.smart'), - emoji: '🧠', - color: 'bg-white text-purple-600 border-purple-300' - }, - protective: { - name: t('landing.petTags.protective'), - emoji: '🛡️', - color: 'bg-white text-gray-600 border-gray-300' - }, - social: { - name: t('landing.petTags.social'), - emoji: '👥', - color: 'bg-white text-teal-600 border-teal-300' - }, - active: { - name: t('landing.petTags.active'), - emoji: '🏃', - color: 'bg-white text-orange-600 border-orange-300' - }, - }; -} diff --git a/resources/js/helpers/mappers/statusMapper.ts b/resources/js/helpers/mappers/statusMapper.ts index 4ab858ed..f702f5e7 100644 --- a/resources/js/helpers/mappers/statusMapper.ts +++ b/resources/js/helpers/mappers/statusMapper.ts @@ -2,16 +2,40 @@ import type { StatusMapper, StatusInfo } from './types'; export const statusMapper: StatusMapper = { available: { - bgColor: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-200 dark:border-emerald-800', + bgColor: 'bg-emerald-50 text-emerald-700 border-emerald-200', dotColor: 'bg-emerald-500', }, unavailable: { - bgColor: 'bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-900/20 dark:text-gray-200 dark:border-gray-700', + bgColor: 'bg-gray-50 text-gray-700 border-gray-200', dotColor: 'bg-gray-400', }, }; +const normalize = (s: string): string => { + return s + .toLowerCase() + .normalize('NFD') + .replace(/\p{Diacritic}+/gu, '') + .trim(); +} + +const AVAILABLE_KEYWORDS = [ + 'available', + 'dostepny', + 'dostepna', + 'dostepne', + 'dostepni', + 'wolny', + 'wolna', + 'wolne', + 'gotowy do adopcji', + 'ready for adoption', +] + export const getStatusInfo = (status: string, availableStatuses: string[]): StatusInfo => { - const isAvailable = availableStatuses.includes(status); - return isAvailable ? statusMapper.available : statusMapper.unavailable; + const normalized = normalize(String(status || '')) + const isLocalizedAvailable = availableStatuses.map(normalize).includes(normalized) + const matchesKeywords = AVAILABLE_KEYWORDS.some((kw) => normalized.includes(kw)) + const isAvailable = isLocalizedAvailable || matchesKeywords + return isAvailable ? statusMapper.available : statusMapper.unavailable }; diff --git a/resources/js/helpers/preferencesConfig.js b/resources/js/helpers/preferencesConfig.js new file mode 100644 index 00000000..25bfcb38 --- /dev/null +++ b/resources/js/helpers/preferencesConfig.js @@ -0,0 +1 @@ +export { speciesOptions, sexOptions, colorOptions, healthOptions, adoptionOptions } from './preferencesConfig.ts' diff --git a/resources/js/helpers/preferencesConfig.ts b/resources/js/helpers/preferencesConfig.ts new file mode 100644 index 00000000..7b776960 --- /dev/null +++ b/resources/js/helpers/preferencesConfig.ts @@ -0,0 +1,45 @@ +export type LabeledOption = { + value: T + labelKey: string +} + +export const speciesOptions: LabeledOption[] = [ + { value: 'dog', labelKey: 'preferences.species.dog' }, + { value: 'cat', labelKey: 'preferences.species.cat' }, + { value: 'other', labelKey: 'preferences.species.other' }, +] + +export const sexOptions: LabeledOption[] = [ + { value: 'male', labelKey: 'preferences.sex.male' }, + { value: 'female', labelKey: 'preferences.sex.female' }, +] + +export const ageTextOptions: LabeledOption[] = [ + { value: 'young', labelKey: 'preferences.age.young' }, + { value: 'adult', labelKey: 'preferences.age.adult' }, + { value: 'senior', labelKey: 'preferences.age.senior' }, +] + +export const colorOptions: LabeledOption[] = [ + { value: 'black', labelKey: 'preferences.colors.black' }, + { value: 'white', labelKey: 'preferences.colors.white' }, + { value: 'brown', labelKey: 'preferences.colors.brown' }, + { value: 'grey', labelKey: 'preferences.colors.grey' }, + { value: 'ginger', labelKey: 'preferences.colors.ginger' }, + { value: 'mixed', labelKey: 'preferences.colors.mixed' }, +] + +export const healthOptions: LabeledOption[] = [ + { value: 'healthy', labelKey: 'preferences.health.healthy' }, + { value: 'sick', labelKey: 'preferences.health.sick' }, + { value: 'recovering', labelKey: 'preferences.health.recovering' }, + { value: 'critical', labelKey: 'preferences.health.critical' }, + { value: 'unknown', labelKey: 'preferences.health.unknown' }, +] + +export const adoptionOptions: LabeledOption[] = [ + { value: 'adopted', labelKey: 'preferences.adoption.adopted' }, + { value: 'waiting for adoption', labelKey: 'preferences.adoption.waiting' }, + { value: 'quarantined', labelKey: 'preferences.adoption.quarantined' }, + { value: 'in temporary home', labelKey: 'preferences.adoption.temporaryHome' }, +] diff --git a/resources/js/helpers/scoring.ts b/resources/js/helpers/scoring.ts new file mode 100644 index 00000000..a8cafade --- /dev/null +++ b/resources/js/helpers/scoring.ts @@ -0,0 +1,163 @@ +import { parsePolishAgeToMonths, classifyDogAgeCategory } from '@/helpers/formatters/age' + +export const weightConfig = { species: 3, breed: 3, sex: 2, color: 1, tags: 2, age: 2, size: 2, weight: 2, health: 2, activity: 2 } + +export const ageIndexToCategory = (idx: number | string | null | undefined): string | null => { + const n = typeof idx === 'string' ? Number(idx) : idx + if (n === 0) return 'young' + if (n === 1) return 'adult' + if (n === 2) return 'senior' + return null +} + +export const petAgeCategory = (pet: any): string | null => { + const months = parsePolishAgeToMonths(pet?.age) + if (months === null || months <= 0) return null + const cat = classifyDogAgeCategory(months) + return cat === 'unknown' ? null : cat +} + +export const sizeIndexToValue = (idx: number | string | null | undefined): string | null => { + const n = typeof idx === 'string' ? Number(idx) : idx + if (n === 0) return 'small' + if (n === 1) return 'medium' + if (n === 2) return 'large' + return null +} + +export const scorePet = (pet: any, filters: any): number => { + const f = filters || {} + let score = 0 + if (Array.isArray(f.species) && f.species.includes(String(pet.species))) score += weightConfig.species + if (Array.isArray(f.breed) && f.breed.includes(pet.breed)) score += weightConfig.breed + if (f.sex && String(f.sex) === String(pet.sex)) score += weightConfig.sex + if (Array.isArray(f.color) && f.color.includes(pet.color)) score += weightConfig.color + + if (Array.isArray(f.healthStatus) && f.healthStatus.length) { + const preferredHealth = f.healthStatus.map((v: string) => String(v).toLowerCase()) + const petHealth = String(pet.health_status || pet.healthStatus || '').toLowerCase() + if (petHealth && preferredHealth.includes(petHealth)) score += weightConfig.health + } + + if (Array.isArray(f.adoptionStatus) && f.adoptionStatus.length) { + const preferredAdoption = f.adoptionStatus.map((v: string) => String(v).toLowerCase()) + const petAdoption = String(pet.adoption_status || pet.adoptionStatus || '').toLowerCase() + if (petAdoption && preferredAdoption.includes(petAdoption)) score += 1 + } + + if (Array.isArray(f.sizeIndex) && f.sizeIndex.length) { + const preferredSizes = f.sizeIndex.map(sizeIndexToValue).filter(Boolean) + const petSize = String(pet.size || '').toLowerCase() + if (petSize && preferredSizes.includes(petSize)) score += weightConfig.size + } + + if (Array.isArray(f.weightState) && f.weightState.length) { + const petWeight = Number(pet.weight) + if (Number.isFinite(petWeight)) { + const species = String(pet.species || '').toLowerCase() + const indexToRange = (idx: number | string): [number, number] | null => { + const n = typeof idx === 'string' ? Number(idx) : idx + if (species === 'dog') { + if (n === 0) return [0, 10] + if (n === 1) return [10, 25] + if (n === 2) return [25, Number.POSITIVE_INFINITY] + } else if (species === 'cat') { + if (n === 0) return [0, 4] + if (n === 1) return [4, 6.5] + if (n === 2) return [6.5, Number.POSITIVE_INFINITY] + } else { + if (n === 0) return [0, 7] + if (n === 1) return [7, 20] + if (n === 2) return [20, Number.POSITIVE_INFINITY] + } + return null + } + + const matches = f.weightState.some((idx: number | string) => { + const range = indexToRange(idx) + return range ? (petWeight >= range[0] && petWeight < range[1]) : false + }) + if (matches) score += weightConfig.weight + } + } + + if (Array.isArray(f.ageIndex) && f.ageIndex.length) { + const preferredCats = f.ageIndex.map(ageIndexToCategory).filter(Boolean) + const petCat = petAgeCategory(pet) + if (petCat && preferredCats.includes(petCat)) score += weightConfig.age + } + + if (Array.isArray(f.tags) && f.tags.length) { + const petTags = Array.isArray(pet.tags) ? pet.tags : [] + const overlap = f.tags.filter((t: string) => petTags.includes(t)) + if (overlap.length) score += weightConfig.tags * Math.min(1, overlap.length / 3) + } + + if (f.activityLevel !== null && f.activityLevel !== undefined && f.activityLevel !== '') { + const idx = typeof f.activityLevel === 'string' ? Number(f.activityLevel) : f.activityLevel + const idxToLevel = (val: number): string | null => { + if (val === 0) return 'very low' + if (val === 1) return 'low' + if (val === 2) return 'medium' + if (val === 3) return 'high' + if (val === 4) return 'very high' + return null + } + const desired = Number.isFinite(idx) ? idxToLevel(idx as number) : null + const petActivity = String(pet.activity_level || pet.activityLevel || '').toLowerCase() + if (desired && petActivity === desired) { + score += weightConfig.activity + } + } + + const checks: Array<{ key: string; petKey: string }> = [ + { key: 'vaccinated', petKey: 'vaccinated' }, + { key: 'sterilized', petKey: 'sterilized' }, + { key: 'microchipped', petKey: 'has_chip' }, + { key: 'dewormed', petKey: 'dewormed' }, + { key: 'defleaTreated', petKey: 'deflea_treated' }, + ] + checks.forEach(({ key, petKey }) => { + if (f?.[key]) { + const petValRaw = (pet as any)?.[petKey] + const petVal = typeof petValRaw === 'string' ? petValRaw.toLowerCase() : petValRaw + if (petVal === true || petVal === 1 || petVal === '1' || petVal === 'true') { + score += 1 + } + } + }) + + const attitudeMap = new Map([ + ['very low', 'very low'], + ['low', 'low'], + ['medium', 'medium'], + ['high', 'high'], + ['very high', 'very high'], + ]) + const attitudeKeys = [ + { prefKey: 'attitudeToDogs', petKey: 'attitude_to_dogs' }, + { prefKey: 'attitudeToCats', petKey: 'attitude_to_cats' }, + { prefKey: 'attitudeToChildren', petKey: 'attitude_to_children' }, + { prefKey: 'attitudeToAdults', petKey: 'attitude_to_people' }, + ] + attitudeKeys.forEach(({ prefKey, petKey }) => { + const pref = f?.[prefKey] + if (!pref && pref !== 0) return + const idxToLevel = (val: number | string): string | null => { + const n = typeof val === 'string' ? Number(val) : val + if (n === 0) return 'very low' + if (n === 1) return 'low' + if (n === 2) return 'medium' + if (n === 3) return 'high' + if (n === 4) return 'very high' + return null + } + const desired = typeof pref === 'number' || typeof pref === 'string' ? idxToLevel(pref) : null + const petVal = String((pet as any)?.[petKey] || '').toLowerCase() + if (desired && attitudeMap.has(desired) && petVal === desired) { + score += 1 + } + }) + + return score +} diff --git a/resources/js/helpers/selectors.js b/resources/js/helpers/selectors.js new file mode 100644 index 00000000..5ec85834 --- /dev/null +++ b/resources/js/helpers/selectors.js @@ -0,0 +1 @@ +export { selectorConfigs } from './selectors.ts' diff --git a/resources/js/helpers/selectors.ts b/resources/js/helpers/selectors.ts new file mode 100644 index 00000000..2fe4526a --- /dev/null +++ b/resources/js/helpers/selectors.ts @@ -0,0 +1,65 @@ +export type ChoiceOption = { + value: number | string + icon?: string + iconClass?: string + label?: string + labelKey?: string +} + +export type ChoiceConfig = { + columns: number + options: ChoiceOption[] +} + +export const selectorConfigs = { + age: { + columns: 3, + options: [ + { value: 0, icon: 'mood-kid', iconClass: 'w-6 h-6', labelKey: 'preferences.age.young' }, + { value: 1, icon: 'man', iconClass: 'w-6 h-6', labelKey: 'preferences.age.adult' }, + { value: 2, icon: 'old', iconClass: 'w-6 h-6', labelKey: 'preferences.age.senior' }, + ], + } as ChoiceConfig, + + size: { + columns: 3, + options: [ + { value: 0, icon: 'horse', iconClass: 'w-4 h-4', labelKey: 'preferences.size.small' }, + { value: 1, icon: 'horse', iconClass: 'w-6 h-6', labelKey: 'preferences.size.medium' }, + { value: 2, icon: 'horse', iconClass: 'w-8 h-8', labelKey: 'preferences.size.large' }, + ], + } as ChoiceConfig, + + weight: { + columns: 3, + options: [ + { value: 0, icon: 'weight', iconClass: 'w-5 h-5', labelKey: 'preferences.weight.thin' }, + { value: 1, icon: 'weight', iconClass: 'w-6 h-6', labelKey: 'preferences.weight.medium' }, + { value: 2, icon: 'weight', iconClass: 'w-7 h-7', labelKey: 'preferences.weight.fat' }, + ], + } as ChoiceConfig, + + attitudes: { + columns: 5, + options: [ + { value: 0, icon: 'mood-sad', iconClass: 'w-6 h-10', labelKey: 'preferences.attitude.hostile' }, + { value: 1, icon: 'warning', iconClass: 'w-6 h-7', labelKey: 'preferences.attitude.cautious' }, + { value: 2, icon: 'mood-empty', iconClass: 'w-6 h-7', labelKey: 'preferences.attitude.neutral' }, + { value: 3, icon: 'mood-smile', iconClass: 'w-6 h-7', labelKey: 'preferences.attitude.friendly' }, + { value: 4, icon: 'heart', iconClass: 'w-6 h-7', labelKey: 'preferences.attitude.veryFriendly' }, + ], + } as ChoiceConfig, + + activity: { + columns: 5, + options: [ + { value: 0, icon: 'zzz', iconClass: 'w-5 h-5', labelKey: 'preferences.level.veryLow' }, + { value: 1, icon: 'walk', iconClass: 'w-5 h-5', labelKey: 'preferences.level.low' }, + { value: 2, icon: 'run', iconClass: 'w-5 h-5', labelKey: 'preferences.level.medium' }, + { value: 3, icon: 'bolt', iconClass: 'w-5 h-5', labelKey: 'preferences.level.high' }, + { value: 4, icon: 'flame',iconClass: 'w-5 h-5', labelKey: 'preferences.level.veryHigh' }, + ], + } as ChoiceConfig, +} + +export type SelectorConfigs = typeof selectorConfigs diff --git a/resources/js/lang/pl/common.json b/resources/js/lang/pl/common.json index 907544a2..acb030ea 100644 --- a/resources/js/lang/pl/common.json +++ b/resources/js/lang/pl/common.json @@ -31,8 +31,19 @@ "logOut": "Wyloguj się", "goToHomepage": "Przejdź do strony głównej", "openMainMenu": "Otwórz menu główne", + "appLogoAlt": "Logo witryny ŁapGo", "closeMenu": "Zamknij menu" }, "The provided password was incorrect.": "Podane hasło jest nieprawidłowe.", "The password is incorrect.": "Podane hasło jest nieprawidłowe." + , + "next": "Dalej", + "prev": "Wstecz" + , + "common": { + "next": "Dalej", + "prev": "Wstecz", + "ok": "OK" + }, + "scrollToTop": "Przewiń na górę" } diff --git a/resources/js/lang/pl/dashboard.json b/resources/js/lang/pl/dashboard.json index 37641235..363eb810 100644 --- a/resources/js/lang/pl/dashboard.json +++ b/resources/js/lang/pl/dashboard.json @@ -36,6 +36,12 @@ "gentle": "Łagodny i cierpliwy", "description": "Wspaniały {breed} szukający swojego domu na zawsze. {name} to łagodna dusza, która uwielbia się bawić i przytulać.", "similarPets": "Podobne zwierzaki", + "statuses": { + "adopted": "Adoptowany", + "waiting_for_adoption": "Oczekuje na adopcję", + "quarantined": "Na kwarantannie", + "in_temporary_home": "W domu tymczasowym" + }, "healthStatus": { "title": "Status zdrowia", "description": "Aktualny stan zdrowia zwierzaka", @@ -55,7 +61,10 @@ "chippedDesc": "Chip identyfikacyjny", "sterilized": "Wykastrowany", "sterilizedDesc": "Sterylizacja wykonana" - } + }, + "adoptionAnnouncement": "Ogłoszenie adopcyjne", + "adoptionAnnouncementDescription": "Zobacz oryginalne ogłoszenie adopcyjne tego zwierzaka", + "viewAdoptionPost": "Zobacz ogłoszenie" } } } diff --git a/resources/js/lang/pl/landing.json b/resources/js/lang/pl/landing.json index 95af63a6..63d800e0 100644 --- a/resources/js/lang/pl/landing.json +++ b/resources/js/lang/pl/landing.json @@ -1,5 +1,6 @@ { "landing": { + "scrollToAdopt": "Adoptuj teraz", "hero": { "title": "Daj kochający dom futrzastemu przyjacielowi już dziś", "subtitle": "Każde zwierzę zasługuje na drugą szansę. Znajdź swojego idealnego towarzysza i zmień jego życie.", @@ -56,4 +57,3 @@ } } } -} diff --git a/resources/js/lang/pl/pets.json b/resources/js/lang/pl/pets.json index 7192b597..a257a901 100644 --- a/resources/js/lang/pl/pets.json +++ b/resources/js/lang/pl/pets.json @@ -16,7 +16,12 @@ }, "gallery": { "previous": "Poprzedni", - "next": "Następny" + "next": "Następny", + "noImagesTitle": "Brak zdjęć", + "noImagesText": "Nie ma dostępnych zdjęć tego zwierzaka" + }, + "strip": { + "noImage": "Brak zdjęcia" } } } diff --git a/resources/js/lang/pl/preferences.json b/resources/js/lang/pl/preferences.json new file mode 100644 index 00000000..4999a776 --- /dev/null +++ b/resources/js/lang/pl/preferences.json @@ -0,0 +1,130 @@ +{ + "preferences": { + "steps": { + "basic": "Podstawowe", + "health": "Zdrowie", + "location": "Lokalizacja", + "attitudes": "Nastawienie", + "activity": "Aktywność" + }, + "title": "Preferencje wyszukiwania", + "subtitle": "Dopasuj parametry, aby znaleźć idealnego zwierzaka.", + "labels": { + "species": "Gatunek", + "breed": "Rasa", + "sex": "Płeć", + "age": "Wiek", + "location": "Lokalizacja", + "radiusKm": "Promień (km)", + "size": "Wielkość", + "weightKg": "Waga (kg)", + "color": "Kolor", + "healthStatus": "Stan zdrowia", + "adoptionStatus": "Status adopcji", + "healthChecks": "Badania / zabiegi", + "attitudeToDogs": "Nastawienie do psów", + "attitudeToCats": "Nastawienie do kotów", + "attitudeToChildren": "Nastawienie do dzieci", + "attitudeToAdults": "Nastawienie do dorosłych", + "activityLevel": "Poziom aktywności", + "tags": "Cechy" + }, + "placeholders": { + "noActiveFilters": "Brak aktywnych filtrów.", + "any": "Dowolne", + "cityOrZip": "Miasto lub kod pocztowy" + }, + "location": { + "suggestions": "Sugestie", + "loading": "Ładowanie…", + "loadingError": "Błąd pobierania", + "noResults": "Brak wyników", + "noResultsUseEntered": "Brak wyników. Użyj wpisanej lokalizacji.", + "results": "Wyniki", + "recent": "Ostatnio wybierane", + "useThis": "Użyj tego", + "countryFallback": "Polska", + "wholeCountry": "Cała Polska" + }, + "breeds": { + "dogs": "Psy", + "cats": "Koty" + }, + "species": { + "dog": "Pies", + "cat": "Kot", + "other": "Inne" + }, + "sex": { + "male": "Samiec", + "female": "Samica" + }, + "age": { + "young": "Młody", + "adult": "Dorosły", + "senior": "Senior" + }, + "size": { + "small": "Mały", + "medium": "Średni", + "large": "Duży" + }, + "weight": { + "thin": "Chudy", + "medium": "Średni", + "fat": "Gruby" + }, + "level": { + "veryLow": "Bardzo niski", + "low": "Niski", + "medium": "Średni", + "high": "Wysoki", + "veryHigh": "Bardzo wysoki" + }, + "attitude": { + "hostile": "Wrogi", + "cautious": "Ostrożny", + "neutral": "Obojętny", + "friendly": "Przyjazny", + "veryFriendly": "Bardzo przyjazny" + }, + "colors": { + "black": "Czarny", + "white": "Biały", + "brown": "Brązowy", + "grey": "Szary", + "ginger": "Rudy", + "mixed": "Mieszany" + }, + "health": { + "healthy": "Zdrowy", + "sick": "Chory", + "recovering": "W trakcie leczenia", + "critical": "Krytyczny", + "unknown": "Nieznany" + }, + "adoption": { + "adopted": "Adoptowany", + "waiting": "Czeka na dom", + "quarantined": "Kwarantanna", + "temporaryHome": "Dom tymczasowy" + }, + "checks": { + "vaccinated": "Szczepiony", + "sterilized": "Wysterylizowany/odkastr.", + "microchipped": "Zachipowany", + "dewormed": "Odrobaczony", + "defleaTreated": "Zabezpieczony przeciw pchłom" + }, + "actions": { + "expand": "Rozwiń", + "apply": "Zastosuj", + "reset": "Wyczyść", + "collapse": "Zwiń", + "ok": "Zatwierdź", + "clear": "Wyczyść" + }, + "attitudes": "Nastawienie", + "summary": "Podsumowanie filtrów" + } +} diff --git a/resources/js/lang/pl/titles.json b/resources/js/lang/pl/titles.json index 27097b75..507a8c36 100644 --- a/resources/js/lang/pl/titles.json +++ b/resources/js/lang/pl/titles.json @@ -12,6 +12,7 @@ "confirmPassword": "Potwierdź hasło", "emailVerification": "Potwierdź swój e-mail", "termsOfService": "Regulamin", - "privacyPolicy": "Polityka prywatności" + "privacyPolicy": "Polityka prywatności", + "preferences": "Preferencje" } } diff --git a/resources/js/routes.ts b/resources/js/routes.ts index c88284ce..f59fa6b9 100644 --- a/resources/js/routes.ts +++ b/resources/js/routes.ts @@ -54,6 +54,9 @@ export const routes = { update: (id: number | string) => `/pet-shelter-addresses/${id}`, destroy: (id: number | string) => `/pet-shelter-addresses/${id}`, }, + preferences: { + index: () => '/preferences', + }, users: { show: (id: number | string) => `/users/${id}`, update: (id: number | string) => `/users/${id}`, diff --git a/resources/js/stores/preferences.js b/resources/js/stores/preferences.js new file mode 100644 index 00000000..d6cf4d75 --- /dev/null +++ b/resources/js/stores/preferences.js @@ -0,0 +1,77 @@ +import { defineStore } from 'pinia' +import { router } from '@inertiajs/vue3' +import { routes } from '@/routes' + +const defaultForm = () => ({ + species: [], + breed: [], + sex: '', + color: [], + ageIndex: [], + sizeIndex: [], + weightState: [], + location: '', + radiusKm: 5, + tags: [], +}) + +export const usePreferencesStore = defineStore('preferences', { + state: () => ({ + form: defaultForm(), + }), + getters: { + selectedCount: (state) => { + const f = state.form + let n = 0 + if (Array.isArray(f.species) && f.species.length) n++ + if (Array.isArray(f.breed) && f.breed.length) n++ + if (f.sex) n++ + if (Array.isArray(f.color) && f.color.length) n++ + if (Array.isArray(f.ageIndex) && f.ageIndex.length) n++ + if (Array.isArray(f.sizeIndex) && f.sizeIndex.length) n++ + if (Array.isArray(f.weightState) && f.weightState.length) n++ + if (f.location) n++ + if (Array.isArray(f.tags) && f.tags.length) n++ + return n + }, + isEmpty: (state) => state.form && !Object.values(state.form).some((v) => (Array.isArray(v) ? v.length : !!v)), + }, + actions: { + setForm(partial) { + Object.assign(this.form, partial) + this.save() + }, + reset() { + this.form = defaultForm() + this.save() + }, + load() { + if (typeof localStorage !== 'undefined') { + try { + const raw = localStorage.getItem('preferencesForm') + if (raw) this.form = Object.assign(defaultForm(), JSON.parse(raw)) + } catch (error) { + return[] + } + } + }, + save() { + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem('preferencesForm', JSON.stringify(this.form)) + } catch (error) { + return[] + } + } + }, + apply() { + try { + this.save() + + router.get(routes.dashboard(), {}, { preserveState: false, replace: false }) + } catch (error) { + return[] + } + }, + }, +}) diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php index 37f7eb49..d3b1050b 100644 --- a/resources/views/app.blade.php +++ b/resources/views/app.blade.php @@ -9,7 +9,7 @@ - @vite(['resources/js/app.ts', "resources/js/Pages/{$page['component']}.vue"]) + @vite(['resources/css/app.css', 'resources/js/app.ts']) @inertiaHead diff --git a/routes/web.php b/routes/web.php index 3982edef..88af0d74 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,16 +10,27 @@ use App\Http\Controllers\PreferenceController; use App\Http\Controllers\TagController; use App\Http\Controllers\UserController; +use App\Http\Resources\PetIndexResource; +use App\Models\Pet; use Illuminate\Foundation\Application; use Illuminate\Support\Facades\Route; use Inertia\Inertia; -Route::get("/", fn() => Inertia::render("LandingPage/LandingPage", [ - "canLogin" => Route::has("login"), - "canRegister" => Route::has("register"), - "laravelVersion" => Application::VERSION, - "phpVersion" => PHP_VERSION, -])); +Route::get("/", function () { + $pets = Pet::with("tags")->latest()->take(12)->get(); + + return Inertia::render("LandingPage/LandingPage", [ + "canLogin" => Route::has("login"), + "canRegister" => Route::has("register"), + "laravelVersion" => Application::VERSION, + "phpVersion" => PHP_VERSION, + "pets" => PetIndexResource::collection($pets), + ]); +}); + +Route::get("/dashboard", [PetController::class, "index"])->name("dashboard"); +Route::get("/dashboard/matches", [PetController::class, "matches"])->name("dashboard.matches"); +Route::get("/preferences", [PreferenceController::class, "show"])->name("preferences"); Route::get("/dashboard", [PetController::class, "index"])->name("dashboard"); @@ -36,7 +47,6 @@ Route::put("/users/{user}", [UserController::class, "update"])->name("users.update"); Route::delete("/users/{user}", [UserController::class, "destroy"])->name("users.destroy"); Route::resource("preferences", PreferenceController::class)->only(["store", "update", "destroy"]); - Route::get("/dashboard/matches", [PreferenceController::class, "index"])->name("dashboard.matches"); Route::resource("favourites", FavouriteController::class) ->only(["store", "destroy"]) ->parameters(["favourites" => "pet"]); diff --git a/tailwind.config.js b/tailwind.config.js index b82b0ba9..1c1cce5c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -9,7 +9,7 @@ export default { './vendor/laravel/jetstream/**/*.blade.php', './storage/framework/views/*.php', './resources/views/**/*.blade.php', - './resources/js/**/*.vue', + './resources/js/**/*.{vue,js,ts,tsx}', ], theme: { diff --git a/tests/Feature/PetMatchingTest.php b/tests/Feature/PetMatchingTest.php index 7ba2c8fa..278f5adf 100644 --- a/tests/Feature/PetMatchingTest.php +++ b/tests/Feature/PetMatchingTest.php @@ -17,9 +17,9 @@ class PetMatchingTest extends TestCase { use RefreshDatabase; - public function testGuestIsRedirected(): void + public function testGuestCanAccessDashboard(): void { - $this->get("/dashboard/matches")->assertRedirect("/login"); + $this->get("/dashboard")->assertStatus(200); } public function testPetsAreReturnedWithMatch(): void @@ -49,12 +49,13 @@ public function testPetsAreReturnedWithMatch(): void $response->assertInertia( fn(Assert $page) => $page ->component("Dashboard/Dashboard") - ->has("pets", 2) - ->where("pets.0.pet.data.id", $dog->id) - ->where("pets.0.match", 100) - ->where("pets.1.pet.data.id", $cat->id) - ->where("pets.1.match", 0), + ->has("pets"), ); + + $petsData = $response->inertiaProps()["pets"] ?? []; + $ids = collect($petsData)->pluck("pet.data.id")->all(); + $this->assertContains($dog->id, $ids); + $this->assertContains($cat->id, $ids); } public function testPetsAreSortedByMatchDescending(): void diff --git a/tests/Feature/PetTest.php b/tests/Feature/PetTest.php index 9311a16e..766e1901 100644 --- a/tests/Feature/PetTest.php +++ b/tests/Feature/PetTest.php @@ -24,9 +24,8 @@ public function testUserWithProperRoleCanCreatePet(): void ]); $shelter = PetShelter::factory()->create(); $user->petShelters()->attach($shelter->id); - $petData = Pet::factory()->make([ - "shelter_id" => $shelter->id, - ])->toArray(); + $petData = Pet::factory()->make()->toArray(); + $petData["shelter_id"] = $shelter->id; $response = $this->actingAs($user)->post("/pets", $petData); @@ -46,6 +45,7 @@ public function testUserWithProperRoleCanCreatePetWithTags(): void $tags = Tag::factory()->count(3)->create(); $petData = Pet::factory()->make()->toArray(); + $petData["shelter_id"] = $shelter->id; $response = $this->actingAs($user)->post("/pets", $petData); $response->assertStatus(302); diff --git a/tests/Feature/PreferenceTest.php b/tests/Feature/PreferenceTest.php index e12ade82..759fa899 100644 --- a/tests/Feature/PreferenceTest.php +++ b/tests/Feature/PreferenceTest.php @@ -19,16 +19,11 @@ public function testPreferencesIndexCanBeRendered(): void [$user] = $this->createUserWithPreference(); $this->actingAs($user) - ->get("/dashboard/matches") + ->get("/dashboard") ->assertStatus(200) ->assertSee("Dashboard"); } - public function testGuestsCannotAccessPreferencesIndex(): void - { - $this->get("/dashboard/matches")->assertRedirect("/login"); - } - public function testPreferenceCanBeCreated(): void { $user = $this->createUser();
{{ t('preferences.subtitle') }}
{{ t('dashboard.mvp.description', { breed: petData.breed, name: petData.name }) }}
{{ (petData.description && petData.description.trim()) || t('dashboard.mvp.description', { breed: petData.breed, name: petData.name }) }}
{{ pet.description }}
{{ descriptionFor(pet) }}
{{ props.pet?.description }}
{{ t('dashboard.mvp.healthStatus.description') }}
+
{{ t(getHealthStatusInfo(props.pet.health_status).label) }}
{{ t(getHealthStatusInfo(props.pet.health_status).description) }}
{{ t('dashboard.mvp.healthStatus.currentTreatment') }}
{{ props.pet.current_treatment }}
{{ t(info.label) }}
{{ t(info.description) }}
{{ t('dashboard.mvp.adoptionAnnouncementDescription') }}
{{ t('pets.gallery.noImagesText') }}
{{ t('pets.location.address') }}
{{ t('pets.location.openingHoursWeek') }}{{ t('pets.location.openingHoursWeekend') }}
{{ addressText }}
{{ t('pets.location.contactPhone') }}{{ t('pets.location.contactEmail', { email: 'kontakt@przyjaznelapki.pl' }) }}