loadCategories(); $this->loadLocations(); $this->hydrateLocationDefaultsFromProfile(); $this->restoreDraft(); } public function render() { return view('panel::quick-create'); } public function dehydrate(): void { if (! $this->shouldPersistDraft) { return; } $this->persistDraft(); } public function updatedPhotos(): void { $this->validatePhotos(); } public function updatedVideos(): void { $this->validateVideos(); } public function updatedSelectedCountryId(): void { $this->selectedCityId = null; } public function removePhoto(int $index): void { if (! isset($this->photos[$index])) { return; } unset($this->photos[$index]); $this->photos = array_values($this->photos); } public function removeVideo(int $index): void { if (! isset($this->videos[$index])) { return; } unset($this->videos[$index]); $this->videos = array_values($this->videos); } public function goToStep(int $step): void { $this->publishError = null; $this->currentStep = max(1, min(self::TOTAL_STEPS, $step)); } public function goToCategoryStep(): void { $this->publishError = null; $this->validatePhotos(); $this->validateVideos(); $this->currentStep = 2; if (! $this->isDetecting && ! $this->detectedCategoryId) { $this->detectCategoryFromImage(); } } public function goToDetailsStep(): void { $this->publishError = null; $this->validateCategoryStep(); $this->currentStep = 3; } public function goToFeaturesStep(): void { $this->publishError = null; $this->validateCategoryStep(); $this->validateDetailsStep(); $this->currentStep = 4; } public function goToPreviewStep(): void { $this->publishError = null; $this->validateCategoryStep(); $this->validateDetailsStep(); $this->validateCustomFieldsStep(); $this->currentStep = 5; } public function detectCategoryFromImage(): void { if ($this->photos === []) { return; } $this->isDetecting = true; $this->detectedError = null; $this->detectedReason = null; $this->detectedAlternatives = []; $result = app(QuickListingCategorySuggester::class)->suggestFromImage($this->photos[0]); $this->isDetecting = false; $this->detectedCategoryId = $result['category_id']; $this->detectedConfidence = $result['confidence']; $this->detectedReason = $result['reason']; $this->detectedError = $result['error']; $this->detectedAlternatives = $result['alternatives']; if ($this->detectedCategoryId) { $this->selectCategory($this->detectedCategoryId); } } public function enterCategory(int $categoryId): void { if (! $this->categoryExists($categoryId)) { return; } $this->activeParentCategoryId = $categoryId; $this->categorySearch = ''; } public function backToRootCategories(): void { $this->activeParentCategoryId = null; $this->categorySearch = ''; } public function selectCategory(int $categoryId): void { if (! $this->categoryExists($categoryId)) { return; } $this->publishError = null; $this->selectedCategoryId = $categoryId; $this->loadListingCustomFields(); } public function publishListing(): void { if ($this->isPublishing) { return; } $this->isPublishing = true; $this->publishError = null; $this->resetErrorBag(); try { $this->validatePhotos(); $this->validateVideos(); $this->validateCategoryStep(); $this->validateDetailsStep(); $this->validateCustomFieldsStep(); $listing = $this->createListing(); } catch (ValidationException $exception) { $this->isPublishing = false; $this->handlePublishValidationFailure($exception); return; } catch (Throwable $exception) { report($exception); $this->isPublishing = false; $this->publishError = 'The listing could not be created. Please try again.'; session()->flash('error', 'The listing could not be created. Please try again.'); return; } $this->isPublishing = false; session()->flash('success', 'Your listing has been created successfully.'); $this->clearDraft(); if (Route::has('panel.listings.edit')) { $this->redirectRoute('panel.listings.edit', ['listing' => $listing->getKey()]); return; } $this->redirectRoute('panel.listings.index'); } public function getRootCategoriesProperty(): array { return collect($this->categories) ->whereNull('parent_id') ->values() ->all(); } public function getCurrentCategoriesProperty(): array { if (! $this->activeParentCategoryId) { return []; } $search = trim((string) $this->categorySearch); $all = collect($this->categories); $parent = $all->firstWhere('id', $this->activeParentCategoryId); $children = $all->where('parent_id', $this->activeParentCategoryId)->values(); $combined = collect(); if (is_array($parent)) { $combined->push($parent); } $combined = $combined->concat($children); return $combined ->when( $search !== '', fn (Collection $categories): Collection => $categories->filter( fn (array $category): bool => str_contains( mb_strtolower($category['name']), mb_strtolower($search) ) ) ) ->values() ->all(); } public function getCurrentParentNameProperty(): string { if (! $this->activeParentCategoryId) { return 'Category Selection'; } $category = collect($this->categories)->firstWhere('id', $this->activeParentCategoryId); return (string) ($category['name'] ?? 'Category Selection'); } public function getCurrentStepTitleProperty(): string { return match ($this->currentStep) { 1 => 'Photos', 2 => 'Category', 3 => 'Basics', 4 => 'Details', 5 => 'Review', default => 'New Listing', }; } public function getCurrentStepHintProperty(): string { return match ($this->currentStep) { 1 => 'Add photos and optional videos first.', 2 => 'Pick the right category.', 3 => 'Add the basics.', 4 => 'Add extra details if needed.', 5 => 'Check everything before publishing.', default => 'Create a new listing.', }; } public function getSelectedCategoryNameProperty(): ?string { if (! $this->selectedCategoryId) { return null; } $category = collect($this->categories)->firstWhere('id', $this->selectedCategoryId); return $category['name'] ?? null; } public function getSelectedCategoryPathProperty(): string { if (! $this->selectedCategoryId) { return ''; } return implode(' › ', $this->categoryPathParts($this->selectedCategoryId)); } public function getDetectedAlternativeNamesProperty(): array { if ($this->detectedAlternatives === []) { return []; } $categoriesById = collect($this->categories)->keyBy('id'); return collect($this->detectedAlternatives) ->map(fn (int $id): ?string => $categoriesById[$id]['name'] ?? null) ->filter() ->values() ->all(); } public function getAvailableCitiesProperty(): array { if (! $this->selectedCountryId) { return []; } $cities = collect($this->cities) ->where('country_id', $this->selectedCountryId) ->values() ->all(); if ($cities !== []) { return $cities; } return [[ 'id' => self::OTHER_CITY_ID, 'name' => 'Other', 'country_id' => $this->selectedCountryId, ]]; } public function getSelectedCountryNameProperty(): ?string { if (! $this->selectedCountryId) { return null; } $country = collect($this->countries)->firstWhere('id', $this->selectedCountryId); return $country['name'] ?? null; } public function getSelectedCityNameProperty(): ?string { if (! $this->selectedCityId) { return null; } if ((int) $this->selectedCityId === self::OTHER_CITY_ID) { return 'Other'; } $city = collect($this->cities)->firstWhere('id', $this->selectedCityId); return $city['name'] ?? null; } public function getPreviewCustomFieldsProperty(): array { return ListingCustomFieldSchemaBuilder::presentableValues( $this->selectedCategoryId, $this->sanitizedCustomFieldValues(), ); } public function getTitleCharactersProperty(): int { return mb_strlen($this->listingTitle); } public function getDescriptionCharactersProperty(): int { return mb_strlen($this->description); } public function getCurrentUserNameProperty(): string { return (string) (auth()->user()?->name ?: 'User'); } public function getCurrentUserInitialProperty(): string { return Str::upper(Str::substr($this->currentUserName, 0, 1)); } public function categoryIconComponent(?string $icon): string { return match ($icon) { 'car' => 'heroicon-o-truck', 'laptop', 'computer' => 'heroicon-o-computer-desktop', 'shirt' => 'heroicon-o-swatch', 'home', 'sofa' => 'heroicon-o-home-modern', 'briefcase' => 'heroicon-o-briefcase', 'wrench' => 'heroicon-o-wrench-screwdriver', 'football' => 'heroicon-o-trophy', 'phone', 'mobile' => 'heroicon-o-device-phone-mobile', default => 'heroicon-o-tag', }; } private function validatePhotos(): void { $this->validate([ 'photos' => [ 'required', 'array', 'min:1', 'max:'.config('quick-listing.max_photo_count', 20), ], 'photos.*' => [ 'required', 'image', 'mimes:jpg,jpeg,png', 'max:'.config('quick-listing.max_photo_size_kb', 5120), ], ]); } private function validateVideos(): void { $this->validate([ 'videos' => [ 'nullable', 'array', 'max:'.config('video.max_listing_videos', 5), ], 'videos.*' => [ 'required', 'file', 'mimetypes:video/mp4,video/quicktime,video/webm,video/x-matroska,video/x-msvideo', 'max:'.config('video.max_upload_size_kb', 102400), ], ]); } private function validateCategoryStep(): void { $this->validate([ 'selectedCategoryId' => [ 'required', 'integer', Rule::in(collect($this->categories)->pluck('id')->all()), ], ], [ 'selectedCategoryId.required' => 'Please choose a category.', 'selectedCategoryId.in' => 'Please choose a valid category.', ]); } private function validateDetailsStep(): void { $this->validate([ 'listingTitle' => ['required', 'string', 'max:70'], 'price' => ['required', 'numeric', 'min:0'], 'description' => ['required', 'string', 'max:1450'], 'selectedCountryId' => ['required', 'integer', Rule::in(collect($this->countries)->pluck('id')->all())], 'selectedCityId' => [ 'nullable', 'integer', function (string $attribute, mixed $value, \Closure $fail): void { if (is_null($value) || $value === '') { return; } $cityExists = collect($this->availableCities) ->contains(fn (array $city): bool => $city['id'] === (int) $value); if (! $cityExists) { $fail('The selected city does not belong to the chosen country.'); } }, ], ], [ 'listingTitle.required' => 'A title is required.', 'listingTitle.max' => 'The title may not exceed 70 characters.', 'price.required' => 'A price is required.', 'price.numeric' => 'The price must be numeric.', 'description.required' => 'A description is required.', 'description.max' => 'The description may not exceed 1450 characters.', 'selectedCountryId.required' => 'Please choose a country.', ]); } private function validateCustomFieldsStep(): void { $rules = []; foreach ($this->listingCustomFields as $field) { $fieldRules = []; $name = $field['name']; $statePath = "customFieldValues.{$name}"; $type = $field['type']; $isRequired = (bool) $field['is_required']; if ($type === ListingCustomField::TYPE_BOOLEAN) { $fieldRules[] = 'nullable'; $fieldRules[] = 'boolean'; } else { $fieldRules[] = $isRequired ? 'required' : 'nullable'; } $fieldRules = [ ...$fieldRules, ...$this->customFieldTypeRules($type), ]; if ($type === ListingCustomField::TYPE_SELECT) { $options = collect($field['options'] ?? [])->map(fn ($option): string => (string) $option)->all(); $fieldRules[] = Rule::in($options); } $rules[$statePath] = $fieldRules; } if ($rules !== []) { $this->validate($rules); } } private function customFieldTypeRules(string $type): array { return match ($type) { ListingCustomField::TYPE_TEXT => ['string', 'max:255'], ListingCustomField::TYPE_TEXTAREA => ['string', 'max:2000'], ListingCustomField::TYPE_NUMBER => ['numeric'], ListingCustomField::TYPE_DATE => ['date'], default => ['sometimes'], }; } private function createListing(): Listing { $user = auth()->user(); if (! $user) { abort(403); } $payload = [ 'title' => trim($this->listingTitle), 'description' => trim($this->description), 'price' => (float) $this->price, 'currency' => ListingPanelHelper::defaultCurrency(), 'category_id' => $this->selectedCategoryId, 'status' => 'pending', 'custom_fields' => $this->sanitizedCustomFieldValues(), 'contact_email' => (string) $user->email, 'contact_phone' => Profile::phoneForUser($user), 'country' => $this->selectedCountryName, 'city' => $this->selectedCityName, ]; $listing = Listing::createFromFrontend($payload, $user->getKey()); $mediaDisk = $this->frontendMediaDisk(); foreach ($this->photos as $photo) { if (! $photo instanceof TemporaryUploadedFile) { continue; } $listing->attachListingImage( $photo->getRealPath(), $photo->getClientOriginalName(), $mediaDisk ); } foreach ($this->videos as $index => $video) { if (! $video instanceof TemporaryUploadedFile) { continue; } Video::createFromTemporaryUpload($listing, $video, [ 'disk' => $mediaDisk, 'sort_order' => $index + 1, 'title' => pathinfo($video->getClientOriginalName(), PATHINFO_FILENAME), ]); } return $listing; } private function sanitizedCustomFieldValues(): array { $fieldsByName = collect($this->listingCustomFields)->keyBy('name'); return collect($this->customFieldValues) ->filter(fn ($value, $key): bool => $fieldsByName->has((string) $key)) ->map(function ($value, $key) use ($fieldsByName): mixed { $field = $fieldsByName->get((string) $key); $type = (string) ($field['type'] ?? ListingCustomField::TYPE_TEXT); return match ($type) { ListingCustomField::TYPE_NUMBER => is_numeric($value) ? (float) $value : null, ListingCustomField::TYPE_BOOLEAN => (bool) $value, default => is_string($value) ? trim($value) : $value, }; }) ->filter(function ($value, $key) use ($fieldsByName): bool { $field = $fieldsByName->get((string) $key); $type = (string) ($field['type'] ?? ListingCustomField::TYPE_TEXT); if ($type === ListingCustomField::TYPE_BOOLEAN) { return true; } return ! is_null($value) && $value !== ''; }) ->all(); } private function loadCategories(): void { $this->categories = Category::panelQuickCatalog(); } private function loadLocations(): void { $this->countries = Country::quickCreateOptions(); $this->cities = City::quickCreateOptions(); } private function loadListingCustomFields(): void { $this->listingCustomFields = ListingCustomField::panelFieldDefinitions($this->selectedCategoryId); $allowed = collect($this->listingCustomFields)->pluck('name')->all(); $this->customFieldValues = collect($this->customFieldValues)->only($allowed)->all(); foreach ($this->listingCustomFields as $field) { if ($field['type'] === ListingCustomField::TYPE_BOOLEAN && ! array_key_exists($field['name'], $this->customFieldValues)) { $this->customFieldValues[$field['name']] = false; } } } private function hydrateLocationDefaultsFromProfile(): void { $user = auth()->user(); if (! $user) { return; } $profile = Profile::detailsForUser($user); if (! $profile) { return; } $profileCountry = trim((string) ($profile->country ?? '')); $profileCity = trim((string) ($profile->city ?? '')); if ($profileCountry !== '') { $country = collect($this->countries)->first(fn (array $country): bool => mb_strtolower($country['name']) === mb_strtolower($profileCountry)); if (is_array($country)) { $this->selectedCountryId = $country['id']; } } if ($profileCity !== '' && $this->selectedCountryId) { $city = collect($this->availableCities)->first(fn (array $city): bool => mb_strtolower($city['name']) === mb_strtolower($profileCity)); if (is_array($city)) { $this->selectedCityId = $city['id']; } } } private function categoryExists(int $categoryId): bool { return collect($this->categories)->contains(fn (array $category): bool => $category['id'] === $categoryId); } private function frontendMediaDisk(): string { return LocalMedia::disk(); } private function handlePublishValidationFailure(ValidationException $exception): void { $errors = $exception->errors(); foreach ($errors as $key => $messages) { foreach ($messages as $message) { $this->addError($key, $message); } } $this->currentStep = $this->stepForValidationErrors(array_keys($errors)); $this->publishError = collect($errors)->flatten()->filter()->first() ?: 'Please fix the highlighted fields before publishing.'; } private function stepForValidationErrors(array $keys): int { $normalizedKeys = collect($keys)->map(fn ($key) => (string) $key)->values(); if ($normalizedKeys->contains(fn ($key) => str_starts_with($key, 'photos') || str_starts_with($key, 'videos'))) { return 1; } if ($normalizedKeys->contains('selectedCategoryId')) { return 2; } if ($normalizedKeys->contains(fn ($key) => in_array($key, [ 'listingTitle', 'price', 'description', 'selectedCountryId', 'selectedCityId', ], true))) { return 3; } if ($normalizedKeys->contains(fn ($key) => str_starts_with($key, 'customFieldValues.'))) { return 4; } return 5; } private function restoreDraft(): void { $draft = session()->get($this->draftSessionKey(), []); if (! is_array($draft) || $draft === []) { return; } $this->currentStep = max(1, min(self::TOTAL_STEPS, (int) ($draft['currentStep'] ?? 1))); $this->categorySearch = (string) ($draft['categorySearch'] ?? ''); $this->selectedCategoryId = isset($draft['selectedCategoryId']) ? (int) $draft['selectedCategoryId'] : null; $this->activeParentCategoryId = isset($draft['activeParentCategoryId']) ? (int) $draft['activeParentCategoryId'] : null; $this->detectedCategoryId = isset($draft['detectedCategoryId']) ? (int) $draft['detectedCategoryId'] : null; $this->detectedConfidence = isset($draft['detectedConfidence']) ? (float) $draft['detectedConfidence'] : null; $this->detectedReason = isset($draft['detectedReason']) ? (string) $draft['detectedReason'] : null; $this->detectedError = isset($draft['detectedError']) ? (string) $draft['detectedError'] : null; $this->detectedAlternatives = collect($draft['detectedAlternatives'] ?? [])->filter(fn ($id) => is_numeric($id))->map(fn ($id) => (int) $id)->values()->all(); $this->listingTitle = (string) ($draft['listingTitle'] ?? ''); $this->price = (string) ($draft['price'] ?? ''); $this->description = (string) ($draft['description'] ?? ''); $this->selectedCountryId = isset($draft['selectedCountryId']) ? (int) $draft['selectedCountryId'] : $this->selectedCountryId; $this->selectedCityId = isset($draft['selectedCityId']) ? (int) $draft['selectedCityId'] : null; $this->customFieldValues = is_array($draft['customFieldValues'] ?? null) ? $draft['customFieldValues'] : []; if ($this->selectedCategoryId) { $this->loadListingCustomFields(); } } private function persistDraft(): void { session()->put($this->draftSessionKey(), [ 'currentStep' => $this->currentStep, 'categorySearch' => $this->categorySearch, 'selectedCategoryId' => $this->selectedCategoryId, 'activeParentCategoryId' => $this->activeParentCategoryId, 'detectedCategoryId' => $this->detectedCategoryId, 'detectedConfidence' => $this->detectedConfidence, 'detectedReason' => $this->detectedReason, 'detectedError' => $this->detectedError, 'detectedAlternatives' => $this->detectedAlternatives, 'listingTitle' => $this->listingTitle, 'price' => $this->price, 'description' => $this->description, 'selectedCountryId' => $this->selectedCountryId, 'selectedCityId' => $this->selectedCityId, 'customFieldValues' => $this->customFieldValues, ]); } private function clearDraft(): void { $this->shouldPersistDraft = false; session()->forget($this->draftSessionKey()); } private function draftSessionKey(): string { $userId = auth()->id() ?: 'guest'; return self::DRAFT_SESSION_KEY.'.'.$userId; } private function categoryPathParts(int $categoryId): array { $byId = collect($this->categories)->keyBy('id'); $parts = []; $currentId = $categoryId; while ($currentId && $byId->has($currentId)) { $category = $byId->get($currentId); if (! is_array($category)) { break; } $parts[] = (string) $category['name']; $currentId = $category['parent_id'] ?? null; } return array_reverse($parts); } }