Düzelt konum ve ilan sayısı

This commit is contained in:
fatihalp 2026-03-07 03:08:00 +03:00
parent 154b226a03
commit 63f2c95fd7
35 changed files with 942 additions and 811 deletions

View File

@ -7,7 +7,6 @@ use App\Support\CountryCodeManager;
use App\Settings\GeneralSettings;
use BackedEnum;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
@ -15,6 +14,7 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Pages\SettingsPage;
use Filament\Schemas\Schema;
use Modules\Admin\Support\HomeSlideFormSchema;
use Tapp\FilamentCountryCodeField\Forms\Components\CountryCodeSelect;
use UnitEnum;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
@ -80,39 +80,10 @@ class ManageGeneralSettings extends SettingsPage
->default($defaults['site_description'])
->rows(3)
->maxLength(500),
Repeater::make('home_slides')
->label('Ana Sayfa Slider')
->schema([
TextInput::make('badge')
->label('Rozet')
->required()
->maxLength(255),
TextInput::make('title')
->label('Başlık')
->required()
->maxLength(255),
Textarea::make('subtitle')
->label('Alt Başlık')
->rows(2)
->required()
->maxLength(500),
TextInput::make('primary_button_text')
->label('Birincil Buton Metni')
->required()
->maxLength(120),
TextInput::make('secondary_button_text')
->label('İkincil Buton Metni')
->required()
->maxLength(120),
])
->default($defaults['home_slides'])
->minItems(1)
->collapsible()
->reorderableWithButtons()
->addActionLabel('Slide Ekle')
->itemLabel(fn (array $state): ?string => filled($state['title'] ?? null) ? (string) $state['title'] : 'Slide')
->afterStateHydrated(fn (Repeater $component, $state) => $component->state($this->normalizeHomeSlides($state)))
->dehydrateStateUsing(fn ($state) => $this->normalizeHomeSlides($state)),
HomeSlideFormSchema::make(
$defaults['home_slides'],
fn ($state): array => $this->normalizeHomeSlides($state),
),
FileUpload::make('site_logo')
->label('Site Logosu')
->image()

View File

@ -0,0 +1,57 @@
<?php
namespace Modules\Admin\Filament\Pages;
use App\Settings\GeneralSettings;
use App\Support\HomeSlideDefaults;
use BackedEnum;
use Filament\Pages\SettingsPage;
use Filament\Schemas\Schema;
use Filament\Support\Enums\Width;
use Modules\Admin\Support\HomeSlideFormSchema;
use UnitEnum;
class ManageHomeSlides extends SettingsPage
{
protected static string $settings = GeneralSettings::class;
protected static ?string $title = 'Home Slides';
protected static ?string $navigationLabel = 'Home Slides';
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-photo';
protected static string | UnitEnum | null $navigationGroup = 'Content';
protected static ?int $navigationSort = 2;
protected Width | string | null $maxContentWidth = Width::Full;
protected function mutateFormDataBeforeFill(array $data): array
{
return [
'home_slides' => $this->normalizeHomeSlides($data['home_slides'] ?? $this->defaultHomeSlides()),
];
}
public function form(Schema $schema): Schema
{
return $schema
->components([
HomeSlideFormSchema::make(
$this->defaultHomeSlides(),
fn ($state): array => $this->normalizeHomeSlides($state),
),
]);
}
private function defaultHomeSlides(): array
{
return HomeSlideDefaults::defaults();
}
private function normalizeHomeSlides(mixed $state): array
{
return HomeSlideDefaults::normalize($state);
}
}

View File

@ -40,12 +40,21 @@ class CategoryResource extends Resource
{
return $table->columns([
TextColumn::make('id')->sortable(),
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('name')
->searchable()
->formatStateUsing(fn (string $state, Category $record): string => $record->parent_id === null ? $state : '↳ ' . $state)
->weight(fn (Category $record): string => $record->parent_id === null ? 'semi-bold' : 'normal'),
TextColumn::make('parent.name')->label('Parent')->default('-'),
TextColumn::make('listings_count')->counts('listings')->label('Listings'),
TextColumn::make('children_count')->label('Subcategories'),
TextColumn::make('listings_count')->label('Listings'),
IconColumn::make('is_active')->boolean(),
TextColumn::make('sort_order')->sortable(),
])->defaultSort('id', 'desc')->actions([
])->actions([
Action::make('toggleChildren')
->label(fn (Category $record, Pages\ListCategories $livewire): string => $livewire->hasExpandedChildren($record) ? 'Hide subcategories' : 'Show subcategories')
->icon(fn (Category $record, Pages\ListCategories $livewire): string => $livewire->hasExpandedChildren($record) ? 'heroicon-o-chevron-down' : 'heroicon-o-chevron-right')
->action(fn (Category $record, Pages\ListCategories $livewire) => $livewire->toggleChildren($record))
->visible(fn (Category $record): bool => $record->parent_id === null && $record->children_count > 0),
EditAction::make(),
Action::make('activities')
->icon('heroicon-o-clock')

View File

@ -3,10 +3,48 @@ namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Livewire\Attributes\Url;
use Modules\Admin\Filament\Resources\CategoryResource;
use Modules\Category\Models\Category;
class ListCategories extends ListRecords
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array { return [CreateAction::make()]; }
#[Url(as: 'expanded')]
public array $expandedParents = [];
protected function getHeaderActions(): array
{
return [CreateAction::make()];
}
public function toggleChildren(Category $record): void
{
if ($record->parent_id !== null || $record->children_count < 1) {
return;
}
$recordId = (int) $record->getKey();
if (in_array($recordId, $this->expandedParents, true)) {
$this->expandedParents = array_values(array_diff($this->expandedParents, [$recordId]));
return;
}
$this->expandedParents[] = $recordId;
$this->expandedParents = array_values(array_unique(array_map('intval', $this->expandedParents)));
}
public function hasExpandedChildren(Category $record): bool
{
return in_array((int) $record->getKey(), $this->expandedParents, true);
}
protected function getTableQuery(): Builder
{
return Category::query()->forAdminHierarchy($this->expandedParents);
}
}

View File

@ -15,6 +15,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\CityResource\Pages;
use Modules\Location\Models\City;
use UnitEnum;
@ -23,7 +24,7 @@ class CityResource extends Resource
{
protected static ?string $model = City::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2';
protected static string | UnitEnum | null $navigationGroup = 'Settings';
protected static string | UnitEnum | null $navigationGroup = 'Location';
protected static ?string $label = 'City';
protected static ?string $pluralLabel = 'Cities';
protected static ?int $navigationSort = 3;
@ -52,6 +53,13 @@ class CityResource extends Resource
->relationship('country', 'name')
->searchable()
->preload(),
TernaryFilter::make('has_districts')
->label('Has districts')
->queries(
true: fn (Builder $query): Builder => $query->has('districts'),
false: fn (Builder $query): Builder => $query->doesntHave('districts'),
blank: fn (Builder $query): Builder => $query,
),
TernaryFilter::make('is_active')->label('Active'),
])->actions([
EditAction::make(),

View File

@ -25,7 +25,7 @@ class DistrictResource extends Resource
{
protected static ?string $model = District::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-map';
protected static string | UnitEnum | null $navigationGroup = 'Settings';
protected static string | UnitEnum | null $navigationGroup = 'Location';
protected static ?string $label = 'District';
protected static ?string $pluralLabel = 'Districts';
protected static ?int $navigationSort = 4;

View File

@ -11,8 +11,10 @@ use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\LocationResource\Pages;
use Modules\Location\Models\Country;
use UnitEnum;
@ -21,7 +23,7 @@ class LocationResource extends Resource
{
protected static ?string $model = Country::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-globe-alt';
protected static string | UnitEnum | null $navigationGroup = 'Settings';
protected static string | UnitEnum | null $navigationGroup = 'Location';
protected static ?string $label = 'Country';
protected static ?string $pluralLabel = 'Countries';
protected static ?int $navigationSort = 2;
@ -47,6 +49,16 @@ class LocationResource extends Resource
IconColumn::make('is_active')->boolean(),
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
])->defaultSort('id', 'desc')->filters([
SelectFilter::make('code')
->label('Code')
->options(fn (): array => Country::query()->orderBy('code')->pluck('code', 'code')->all()),
TernaryFilter::make('has_cities')
->label('Has cities')
->queries(
true: fn (Builder $query): Builder => $query->has('cities'),
false: fn (Builder $query): Builder => $query->doesntHave('cities'),
blank: fn (Builder $query): Builder => $query,
),
TernaryFilter::make('is_active')->label('Active'),
])->actions([
EditAction::make(),

View File

@ -7,6 +7,7 @@ use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Navigation\MenuItem;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
@ -24,7 +25,6 @@ use Modules\Admin\Filament\Resources\CategoryResource;
use Modules\Admin\Filament\Resources\ListingResource;
use Modules\Admin\Filament\Resources\LocationResource;
use Modules\Admin\Filament\Resources\UserResource;
use TallCms\Cms\TallCmsPlugin;
class AdminPanelProvider extends PanelProvider
{
@ -39,10 +39,15 @@ class AdminPanelProvider extends PanelProvider
->discoverResources(in: module_path('Admin', 'Filament/Resources'), for: 'Modules\\Admin\\Filament\\Resources')
->discoverPages(in: module_path('Admin', 'Filament/Pages'), for: 'Modules\\Admin\\Filament\\Pages')
->discoverWidgets(in: module_path('Admin', 'Filament/Widgets'), for: 'Modules\\Admin\\Filament\\Widgets')
->userMenuItems([
'view-site' => MenuItem::make()
->label('View Site')
->icon('heroicon-o-globe-alt')
->url(fn (): string => url('/'))
->sort(-2),
])
->plugins([
FilamentStateFusionPlugin::make(),
TallCmsPlugin::make()
->withoutUsers(),
BreezyCore::make()
->myProfile(
shouldRegisterNavigation: true,

View File

@ -0,0 +1,63 @@
<?php
namespace Modules\Admin\Support;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
final class HomeSlideFormSchema
{
public static function make(array $defaults, callable $normalizeSlides): Repeater
{
return Repeater::make('home_slides')
->label('Homepage Slides')
->helperText('Use 1 to 5 slides. Upload a wide image for each slide to improve the hero area.')
->schema([
FileUpload::make('image_path')
->label('Slide Image')
->image()
->disk('public')
->directory('home-slides')
->visibility('public')
->imageEditor()
->imagePreviewHeight('200')
->helperText('Recommended: 1600x1000 or wider.')
->columnSpanFull(),
TextInput::make('badge')
->label('Badge')
->maxLength(255),
TextInput::make('title')
->label('Title')
->required()
->maxLength(255),
Textarea::make('subtitle')
->label('Subtitle')
->rows(3)
->required()
->maxLength(500)
->columnSpanFull(),
TextInput::make('primary_button_text')
->label('Primary Button')
->required()
->maxLength(120),
TextInput::make('secondary_button_text')
->label('Secondary Button')
->required()
->maxLength(120),
])
->columns(2)
->default($defaults)
->minItems(1)
->maxItems(5)
->collapsible()
->collapsed()
->cloneable()
->reorderableWithButtons()
->addActionLabel('Add Slide')
->itemLabel(fn (array $state): string => filled($state['title'] ?? null) ? (string) $state['title'] : 'New Slide')
->afterStateHydrated(fn (Repeater $component, $state) => $component->state($normalizeSlides($state)))
->dehydrateStateUsing(fn ($state) => $normalizeSlides($state));
}
}

View File

@ -3,8 +3,8 @@ namespace Modules\Category\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Modules\Listing\Models\Listing;
use Spatie\Activitylog\LogOptions;
@ -50,6 +50,34 @@ class Category extends Model
return $query->orderBy('sort_order')->orderBy('name');
}
public function scopeForAdminHierarchy(Builder $query, array $expandedParentIds = []): Builder
{
$expandedParentIds = collect($expandedParentIds)
->map(fn ($id): int => (int) $id)
->filter()
->unique()
->values()
->all();
return $query
->select('categories.*')
->leftJoin('categories as parent_categories', 'categories.parent_id', '=', 'parent_categories.id')
->with(['parent:id,name'])
->withCount(['children', 'listings'])
->where(function (Builder $nestedQuery) use ($expandedParentIds): void {
$nestedQuery->whereNull('categories.parent_id');
if ($expandedParentIds !== []) {
$nestedQuery->orWhereIn('categories.parent_id', $expandedParentIds);
}
})
->orderByRaw('COALESCE(parent_categories.sort_order, categories.sort_order)')
->orderByRaw('COALESCE(parent_categories.name, categories.name)')
->orderByRaw('CASE WHEN categories.parent_id IS NULL THEN 0 ELSE 1 END')
->orderBy('categories.sort_order')
->orderBy('categories.name');
}
public static function filterOptions(): Collection
{
return static::query()

View File

@ -16,13 +16,15 @@ class ConversationController extends Controller
{
public function inbox(Request $request): View
{
$userId = (int) $request->user()->getKey();
$user = $request->user();
$userId = $user ? (int) $user->getKey() : null;
$requiresLogin = ! $user;
$messageFilter = $this->resolveMessageFilter($request);
$conversations = collect();
$selectedConversation = null;
if ($this->messagingTablesReady()) {
if ($userId && $this->messagingTablesReady()) {
try {
$conversations = Conversation::inboxForUser($userId, $messageFilter);
$selectedConversation = Conversation::resolveSelected($conversations, $request->integer('conversation'));
@ -50,6 +52,7 @@ class ConversationController extends Controller
'selectedConversation' => $selectedConversation,
'messageFilter' => $messageFilter,
'quickMessages' => QuickMessageCatalog::all(),
'requiresLogin' => $requiresLogin,
]);
}

View File

@ -8,6 +8,18 @@
@include('panel.partials.sidebar', ['activeMenu' => 'inbox'])
<section class="bg-white border border-slate-200 rounded-xl p-0 overflow-hidden">
@if($requiresLogin ?? false)
<div class="border-b border-slate-200 px-5 py-4 bg-slate-50 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-xl font-semibold text-slate-900">Inbox</h1>
<p class="text-sm text-slate-500 mt-1">Stay on this page and log in when you want to access your conversations.</p>
</div>
<a href="{{ route('login', ['redirect' => request()->fullUrl()]) }}" class="inline-flex items-center justify-center rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white hover:bg-slate-800 transition">
Log in
</a>
</div>
@endif
<div class="grid grid-cols-1 xl:grid-cols-[420px,1fr] min-h-[620px]">
<div class="border-b xl:border-b-0 xl:border-r border-slate-200">
<div class="px-6 py-5 border-b border-slate-200 flex items-center justify-between gap-3">

View File

@ -3,7 +3,7 @@
use Illuminate\Support\Facades\Route;
use Modules\Conversation\App\Http\Controllers\ConversationController;
Route::middleware('auth')->prefix('panel')->name('panel.')->group(function () {
Route::prefix('panel')->name('panel.')->group(function () {
Route::get('/inbox', [ConversationController::class, 'inbox'])->name('inbox.index');
});

View File

@ -39,6 +39,7 @@ class FavoriteController extends Controller
}
$user = $request->user();
$requiresLogin = ! $user;
$categories = collect();
if ($this->tableExists('categories')) {
@ -55,7 +56,7 @@ class FavoriteController extends Controller
$selectedConversation = null;
$buyerConversationListingMap = [];
if ($activeTab === 'listings') {
if ($user && $activeTab === 'listings') {
try {
if ($this->tableExists('favorite_listings')) {
$favoriteListings = $user->favoriteListings()
@ -100,7 +101,7 @@ class FavoriteController extends Controller
}
}
if ($activeTab === 'searches') {
if ($user && $activeTab === 'searches') {
try {
if ($this->tableExists('favorite_searches')) {
$favoriteSearches = $user->favoriteSearches()
@ -114,7 +115,7 @@ class FavoriteController extends Controller
}
}
if ($activeTab === 'sellers') {
if ($user && $activeTab === 'sellers') {
try {
if ($this->tableExists('favorite_sellers')) {
$favoriteSellers = $user->favoriteSellers()
@ -143,6 +144,7 @@ class FavoriteController extends Controller
'selectedConversation' => $selectedConversation,
'buyerConversationListingMap' => $buyerConversationListingMap,
'quickMessages' => QuickMessageCatalog::all(),
'requiresLogin' => $requiresLogin,
]);
}

View File

@ -8,6 +8,18 @@
@include('panel.partials.sidebar', ['activeMenu' => 'favorites', 'activeFavoritesTab' => $activeTab])
<section class="bg-white border border-slate-200">
@if($requiresLogin ?? false)
<div class="border-b border-slate-200 px-5 py-4 bg-slate-50 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-xl font-semibold text-slate-900">Favorites</h1>
<p class="text-sm text-slate-500 mt-1">Stay on this page and log in when you want to sync saved listings, searches, and sellers.</p>
</div>
<a href="{{ route('login', ['redirect' => request()->fullUrl()]) }}" class="inline-flex items-center justify-center rounded-full bg-slate-900 px-5 py-2.5 text-sm font-semibold text-white hover:bg-slate-800 transition">
Log in
</a>
</div>
@endif
@if($activeTab === 'listings')
@php
$listingTabQuery = array_filter([

View File

@ -3,8 +3,11 @@
use Illuminate\Support\Facades\Route;
use Modules\Favorite\App\Http\Controllers\FavoriteController;
Route::middleware('auth')->prefix('favorites')->name('favorites.')->group(function () {
Route::prefix('favorites')->name('favorites.')->group(function () {
Route::get('/', [FavoriteController::class, 'index'])->name('index');
});
Route::middleware('auth')->prefix('favorites')->name('favorites.')->group(function () {
Route::post('/listings/{listing}/toggle', [FavoriteController::class, 'toggleListing'])->name('listings.toggle');
Route::post('/sellers/{seller}/toggle', [FavoriteController::class, 'toggleSeller'])->name('sellers.toggle');
Route::post('/searches', [FavoriteController::class, 'storeSearch'])->name('searches.store');

View File

@ -4,7 +4,6 @@ namespace Modules\Listing\Http\Controllers;
use App\Http\Controllers\Controller;
use Modules\Conversation\App\Models\Conversation;
use Modules\Favorite\App\Models\FavoriteSearch;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\Location\Models\City;
@ -67,18 +66,28 @@ class ListingController extends Controller
$listingDirectory = Category::listingDirectory($categoryId);
$browseFilters = [
'search' => $search,
'country' => $selectedCountryName,
'city' => $selectedCityName,
'min_price' => $minPrice,
'max_price' => $maxPrice,
'date_filter' => $dateFilter,
];
$allListingsTotal = Listing::query()
->active()
->forBrowseFilters($browseFilters)
->count();
$listingsQuery = Listing::query()
->active()
->with('category:id,name')
->searchTerm($search)
->forCategoryIds($listingDirectory['filterIds'])
->when($selectedCountryName, fn ($query) => $query->where('country', $selectedCountryName))
->when($selectedCityName, fn ($query) => $query->where('city', $selectedCityName))
->when(! is_null($minPrice), fn ($query) => $query->whereNotNull('price')->where('price', '>=', $minPrice))
->when(! is_null($maxPrice), fn ($query) => $query->whereNotNull('price')->where('price', '<=', $maxPrice));
$this->applyDateFilter($listingsQuery, $dateFilter);
$this->applySorting($listingsQuery, $sort);
->forBrowseFilters([
...$browseFilters,
'category_ids' => $listingDirectory['filterIds'],
])
->applyBrowseSort($sort);
$listings = $listingsQuery
->paginate(16)
@ -136,6 +145,7 @@ class ListingController extends Controller
'favoriteListingIds',
'isCurrentSearchSaved',
'conversationListingMap',
'allListingsTotal',
));
}
@ -302,24 +312,4 @@ class ListingController extends Controller
}
}
private function applyDateFilter($query, string $dateFilter): void
{
match ($dateFilter) {
'today' => $query->where('created_at', '>=', Carbon::now()->startOfDay()),
'week' => $query->where('created_at', '>=', Carbon::now()->subDays(7)),
'month' => $query->where('created_at', '>=', Carbon::now()->subDays(30)),
default => null,
};
}
private function applySorting($query, string $sort): void
{
match ($sort) {
'newest' => $query->reorder()->orderByDesc('created_at'),
'oldest' => $query->reorder()->orderBy('created_at'),
'price_asc' => $query->reorder()->orderByRaw('price is null')->orderBy('price'),
'price_desc' => $query->reorder()->orderByRaw('price is null')->orderByDesc('price'),
default => $query->reorder()->orderByDesc('is_featured')->orderByDesc('created_at'),
};
}
}

View File

@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
@ -118,6 +119,43 @@ class Listing extends Model implements HasMedia
return $query->whereIn('category_id', $categoryIds);
}
public function scopeForBrowseFilters(Builder $query, array $filters): Builder
{
$search = trim((string) ($filters['search'] ?? ''));
$country = isset($filters['country']) ? trim((string) $filters['country']) : null;
$city = isset($filters['city']) ? trim((string) $filters['city']) : null;
$minPrice = is_numeric($filters['min_price'] ?? null) ? max((float) $filters['min_price'], 0) : null;
$maxPrice = is_numeric($filters['max_price'] ?? null) ? max((float) $filters['max_price'], 0) : null;
$dateFilter = (string) ($filters['date_filter'] ?? 'all');
$categoryIds = $filters['category_ids'] ?? null;
$query
->searchTerm($search)
->forCategoryIds(is_array($categoryIds) ? $categoryIds : null)
->when($country !== null && $country !== '', fn (Builder $builder) => $builder->where('country', $country))
->when($city !== null && $city !== '', fn (Builder $builder) => $builder->where('city', $city))
->when(! is_null($minPrice), fn (Builder $builder) => $builder->whereNotNull('price')->where('price', '>=', $minPrice))
->when(! is_null($maxPrice), fn (Builder $builder) => $builder->whereNotNull('price')->where('price', '<=', $maxPrice));
return match ($dateFilter) {
'today' => $query->where('created_at', '>=', Carbon::now()->startOfDay()),
'week' => $query->where('created_at', '>=', Carbon::now()->subDays(7)),
'month' => $query->where('created_at', '>=', Carbon::now()->subDays(30)),
default => $query,
};
}
public function scopeApplyBrowseSort(Builder $query, string $sort): Builder
{
return match ($sort) {
'newest' => $query->reorder()->orderByDesc('created_at'),
'oldest' => $query->reorder()->orderBy('created_at'),
'price_asc' => $query->reorder()->orderByRaw('price is null')->orderBy('price'),
'price_desc' => $query->reorder()->orderByRaw('price is null')->orderByDesc('price'),
default => $query->reorder()->orderByDesc('is_featured')->orderByDesc('created_at'),
};
}
public function themeGallery(): array
{
$mediaUrls = $this->getMedia('listing-images')

View File

@ -1,7 +1,7 @@
@extends('app::layouts.app')
@section('content')
@php
$totalListings = (int) $listings->total();
$totalListings = isset($allListingsTotal) ? (int) $allListingsTotal : (int) $listings->total();
$activeCategoryName = $selectedCategory?->name ? trim((string) $selectedCategory->name) : '';
$pageTitle = $activeCategoryName !== ''
? 'İkinci El '.$activeCategoryName.' İlanları ve Fiyatları'

View File

@ -1,7 +1,7 @@
@extends('app::layouts.app')
@section('content')
@php
$totalListings = (int) $listings->total();
$totalListings = isset($allListingsTotal) ? (int) $allListingsTotal : (int) $listings->total();
$activeCategoryName = $selectedCategory?->name ? trim((string) $selectedCategory->name) : '';
$pageTitle = $activeCategoryName !== ''
? 'İkinci El '.$activeCategoryName.' İlanları ve Fiyatları'

View File

@ -1,7 +1,7 @@
@extends('app::layouts.app')
@section('content')
@php
$totalListings = (int) $listings->total();
$totalListings = isset($allListingsTotal) ? (int) $allListingsTotal : (int) $listings->total();
$activeCategoryName = $selectedCategory?->name ? trim((string) $selectedCategory->name) : '';
$pageTitle = $activeCategoryName !== ''
? 'İkinci El '.$activeCategoryName.' İlanları ve Fiyatları'

View File

@ -135,6 +135,16 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata
return filled($this->avatar_url) ? Storage::disk('public')->url($this->avatar_url) : null;
}
public function getDisplayName(): string
{
return trim((string) ($this->name ?: $this->email ?: 'User'));
}
public function getEmail(): string
{
return trim((string) $this->email);
}
public function toggleFavoriteListing(Listing $listing): bool
{
$isFavorite = $this->favoriteListings()->whereKey($listing->getKey())->exists();

View File

@ -11,19 +11,27 @@ use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
$redirectTo = $this->sanitizeRedirectTarget(request()->query('redirect'));
if ($redirectTo) {
request()->session()->put('url.intended', $redirectTo);
}
return view('auth.login', [
'redirectTo' => $redirectTo,
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$redirectTo = $this->sanitizeRedirectTarget($request->input('redirect'));
if ($redirectTo) {
$request->session()->put('url.intended', $redirectTo);
}
$request->authenticate();
$request->session()->regenerate();
@ -31,9 +39,6 @@ class AuthenticatedSessionController extends Controller
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
@ -44,4 +49,34 @@ class AuthenticatedSessionController extends Controller
return redirect('/');
}
private function sanitizeRedirectTarget(?string $target): ?string
{
$target = trim((string) $target);
if ($target === '' || str_starts_with($target, '//')) {
return null;
}
if (str_starts_with($target, '/')) {
return $target;
}
if (! filter_var($target, FILTER_VALIDATE_URL)) {
return null;
}
$applicationUrl = parse_url(url('/'));
$targetUrl = parse_url($target);
if (($applicationUrl['host'] ?? null) !== ($targetUrl['host'] ?? null)) {
return null;
}
$path = $targetUrl['path'] ?? '/';
$query = isset($targetUrl['query']) ? '?' . $targetUrl['query'] : '';
$fragment = isset($targetUrl['fragment']) ? '#' . $targetUrl['fragment'] : '';
return $path . $query . $fragment;
}
}

View File

@ -2,40 +2,45 @@
namespace App\Support;
use Illuminate\Support\Arr;
final class HomeSlideDefaults
{
/**
* @return array<int, array{badge: string, title: string, subtitle: string, primary_button_text: string, secondary_button_text: string}>
* @return array<int, array{badge: string, title: string, subtitle: string, primary_button_text: string, secondary_button_text: string, image_path: string}>
*/
public static function defaults(): array
{
return [
[
'badge' => 'Vitrin İlanları',
'title' => 'İlan ücreti ödemeden ürününü dakikalar içinde yayına al.',
'subtitle' => 'Mahallendeki alıcılarla hızlıca buluş, pazarlığı doğrudan mesajla tamamla.',
'primary_button_text' => 'İlanları İncele',
'secondary_button_text' => 'İlan Ver',
'badge' => 'Featured Marketplace',
'title' => 'List products in minutes and reach local buyers faster.',
'subtitle' => 'A calm, simple marketplace for everyday electronics, home finds, and local deals.',
'primary_button_text' => 'Browse Listings',
'secondary_button_text' => 'Post Listing',
'image_path' => 'images/home-slides/slide-marketplace.svg',
],
[
'badge' => 'Günün Fırsatları',
'title' => 'Elektronikten araca kadar her kategoride canlı ilanlar seni bekliyor.',
'subtitle' => 'Kategorilere göz at, favorilerine ekle ve satıcılarla tek tıkla iletişime geç.',
'primary_button_text' => 'Kategorileri Gör',
'secondary_button_text' => 'Hemen Başla',
'badge' => 'Fresh Categories',
'title' => 'Explore electronics, vehicles, fashion, and home in one clean flow.',
'subtitle' => 'Move between categories quickly, compare listings, and message sellers without friction.',
'primary_button_text' => 'See Categories',
'secondary_button_text' => 'Start Now',
'image_path' => 'images/home-slides/slide-categories.svg',
],
[
'badge' => 'Yerel Alışveriş',
'title' => 'Konumuna en yakın ikinci el fırsatları tek ekranda keşfet.',
'subtitle' => 'Şehrini seç, sana en yakın ilanları filtrele ve güvenle alışveriş yap.',
'primary_button_text' => 'Yakındaki İlanlar',
'secondary_button_text' => 'Ücretsiz İlan Ver',
'badge' => 'Local Shopping',
'title' => 'Discover nearby second-hand picks with a more polished storefront.',
'subtitle' => 'Filter by city, save favorites, and turn local demand into quick conversations.',
'primary_button_text' => 'Nearby Deals',
'secondary_button_text' => 'Sell for Free',
'image_path' => 'images/home-slides/slide-local.svg',
],
];
}
/**
* @return array<int, array{badge: string, title: string, subtitle: string, primary_button_text: string, secondary_button_text: string}>
* @return array<int, array{badge: string, title: string, subtitle: string, primary_button_text: string, secondary_button_text: string, image_path: string|null}>
*/
public static function normalize(mixed $slides): array
{
@ -52,6 +57,7 @@ final class HomeSlideDefaults
$subtitle = trim((string) ($slide['subtitle'] ?? ''));
$primaryButtonText = trim((string) ($slide['primary_button_text'] ?? ''));
$secondaryButtonText = trim((string) ($slide['secondary_button_text'] ?? ''));
$imagePath = self::normalizeImagePath($slide['image_path'] ?? null);
if ($title === '') {
return null;
@ -63,6 +69,7 @@ final class HomeSlideDefaults
'subtitle' => $subtitle !== '' ? $subtitle : $fallback['subtitle'],
'primary_button_text' => $primaryButtonText !== '' ? $primaryButtonText : $fallback['primary_button_text'],
'secondary_button_text' => $secondaryButtonText !== '' ? $secondaryButtonText : $fallback['secondary_button_text'],
'image_path' => $imagePath !== '' ? $imagePath : ($fallback['image_path'] ?? null),
];
})
->filter(fn ($slide): bool => is_array($slide))
@ -74,4 +81,19 @@ final class HomeSlideDefaults
->values()
->all();
}
private static function normalizeImagePath(mixed $value): string
{
if (is_string($value)) {
return trim($value);
}
if (is_array($value)) {
$firstValue = Arr::first($value, fn ($item): bool => is_string($item) && trim($item) !== '');
return is_string($firstValue) ? trim($firstValue) : '';
}
return '';
}
}

View File

@ -28,7 +28,6 @@
"spatie/laravel-permission": "^6.24",
"spatie/laravel-settings": "^3.7",
"stechstudio/filament-impersonate": "^5.1",
"tallcms/cms": "^3.2",
"tapp/filament-country-code-field": "^2.0",
"ysfkaya/filament-phone-input": "^4.1"
},

View File

@ -1,569 +0,0 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| TallCMS Version
|--------------------------------------------------------------------------
|
| The current version of TallCMS. Read dynamically from composer.json
| to ensure it's always in sync with the installed package version.
|
*/
'version' => (function () {
$composerJson = dirname(__DIR__).'/composer.json';
if (file_exists($composerJson)) {
$data = json_decode(file_get_contents($composerJson), true);
return $data['version'] ?? 'unknown';
}
return 'unknown';
})(),
/*
|--------------------------------------------------------------------------
| Operation Mode
|--------------------------------------------------------------------------
|
| Determines how TallCMS operates. Auto-detection works in most cases:
| - 'standalone': Full TallCMS installation (tallcms/tallcms skeleton)
| - 'plugin': Installed as a plugin in existing Filament app
| - null: Auto-detect based on .tallcms-standalone marker file
|
*/
'mode' => env('TALLCMS_MODE'),
/*
|--------------------------------------------------------------------------
| Database Configuration
|--------------------------------------------------------------------------
|
| Table prefix for all TallCMS tables. Default 'tallcms_' maintains
| compatibility with v1.x installations. Can be customized in plugin
| mode to avoid conflicts with existing tables.
|
*/
'database' => [
'prefix' => env('TALLCMS_TABLE_PREFIX', 'tallcms_'),
],
/*
|--------------------------------------------------------------------------
| Plugin Mode Settings
|--------------------------------------------------------------------------
|
| Configuration specific to plugin mode operation. These settings are
| ignored in standalone mode.
|
*/
'plugin_mode' => [
// Enable frontend CMS page routes.
// When enabled, TallCMS registers both / (homepage) and /{slug} routes.
// WARNING: Without a prefix, this will override your app's homepage route!
'routes_enabled' => env('TALLCMS_ROUTES_ENABLED', false),
// Optional URL prefix for CMS routes (e.g., 'cms' results in /cms and /cms/{slug})
// Leave empty for root-level routes (/, /about, /contact)
// When empty, smart exclusions prevent conflicts with your app routes.
'routes_prefix' => env('TALLCMS_ROUTES_PREFIX', ''),
// Route name prefix for plugin mode (e.g., 'tallcms.' results in tallcms.cms.page)
'route_name_prefix' => env('TALLCMS_PLUGIN_ROUTE_NAME_PREFIX', 'tallcms.'),
// Route exclusion pattern - paths matching this regex are excluded from CMS routing.
// Default excludes common Laravel/Filament paths. Panel path is auto-excluded.
//
// In NON-i18n mode with standard format (^(?!foo|bar).*$): Merged with base exclusions.
// In NON-i18n mode with custom regex: Used as-is, replaces default pattern entirely.
// NOTE: When using custom regex, 'additional_exclusions' is ignored.
// In i18n mode: Only standard negative lookahead format is merged; other formats ignored.
'route_exclusions' => env('TALLCMS_PLUGIN_ROUTE_EXCLUSIONS',
env('TALLCMS_ROUTE_EXCLUSIONS', // backward compat
'^(?!admin|app|api|livewire|sanctum|storage|build|vendor|health|_).*$'
)
),
// Additional route exclusions as pipe-separated list (e.g., 'dashboard|settings|profile').
// Merged with base exclusions when using standard route_exclusions format.
// NOTE: Ignored when route_exclusions is set to a non-standard custom regex.
// Recommended for i18n mode where custom regex is not supported.
'additional_exclusions' => env('TALLCMS_ADDITIONAL_EXCLUSIONS', ''),
// Enable preview routes (/preview/page/{id}, /preview/post/{id})
'preview_routes_enabled' => env('TALLCMS_PREVIEW_ROUTES_ENABLED', true),
// Enable API routes (/api/contact)
'api_routes_enabled' => env('TALLCMS_API_ROUTES_ENABLED', true),
// Optional prefix for essential routes (preview, contact API) to avoid conflicts
// e.g., 'tallcms' results in /tallcms/preview/page/{id}
'essential_routes_prefix' => env('TALLCMS_ESSENTIAL_ROUTES_PREFIX', ''),
// Enable core SEO routes (sitemap.xml, robots.txt).
// These are always registered at root level (no prefix) since search
// engines expect them at standard locations. Safe to enable.
'seo_routes_enabled' => env('TALLCMS_SEO_ROUTES_ENABLED', true),
// Enable archive routes (RSS feed, category archives, author archives).
// These routes (/feed, /category/{slug}, /author/{slug}) may conflict
// with your app's routes. Disabled by default in plugin mode.
'archive_routes_enabled' => env('TALLCMS_ARCHIVE_ROUTES_ENABLED', false),
// Optional prefix for archive routes to avoid conflicts.
// e.g., 'blog' results in /blog/feed, /blog/category/{slug}, /blog/author/{slug}
'archive_routes_prefix' => env('TALLCMS_ARCHIVE_ROUTES_PREFIX', ''),
// Enable the TallCMS plugin system.
// When enabled, the Plugin Manager page is visible and third-party plugins can be loaded.
'plugins_enabled' => env('TALLCMS_PLUGINS_ENABLED', true),
// Enable the TallCMS theme system.
// When enabled, the Theme Manager page is visible and themes can be loaded.
'themes_enabled' => env('TALLCMS_THEMES_ENABLED', true),
// User model class. Must implement TallCmsUserContract.
// Default works with standard Laravel User model with HasRoles trait.
'user_model' => env('TALLCMS_USER_MODEL', 'App\\Models\\User'),
// Skip installer.lock check for maintenance mode in plugin mode.
// In plugin mode, the host app doesn't use TallCMS's installer,
// so we assume the app is properly installed. Default: true
'skip_installer_check' => env('TALLCMS_SKIP_INSTALLER_CHECK', true),
],
/*
|--------------------------------------------------------------------------
| Authentication Configuration
|--------------------------------------------------------------------------
|
| Configuration for authentication guards used by TallCMS roles and
| permissions. This should match your Filament panel's guard.
|
*/
'auth' => [
// Guard name for roles and permissions (should match Filament panel guard)
'guard' => env('TALLCMS_AUTH_GUARD', 'web'),
// Login route for preview authentication redirect
// Can be a route name (e.g., 'filament.admin.auth.login') or URL
// Leave null to auto-detect Filament's login route
'login_route' => env('TALLCMS_LOGIN_ROUTE'),
],
/*
|--------------------------------------------------------------------------
| Filament Panel Configuration
|--------------------------------------------------------------------------
|
| These settings are dynamically set by TallCmsPlugin when registered.
| They allow customization of navigation group and sort order.
|
*/
'filament' => [
// Panel ID for route generation in notifications
// Used for constructing admin panel URLs like filament.{panel_id}.resources.*
'panel_id' => env('TALLCMS_PANEL_ID', 'admin'),
// Panel path for URL construction and middleware exclusions
'panel_path' => env('TALLCMS_PANEL_PATH', 'admin'),
// Navigation group override - when set, CMS resources/pages use this group.
// Note: UserResource stays in 'User Management' regardless of this setting.
// Leave unset (null) to use per-resource defaults (Content Management, Settings, etc.)
'navigation_group' => env('TALLCMS_NAVIGATION_GROUP'),
// Navigation sort override - when set, CMS resources/pages use this sort.
// Leave unset (null) to use per-resource defaults.
'navigation_sort' => env('TALLCMS_NAVIGATION_SORT') !== null
? (int) env('TALLCMS_NAVIGATION_SORT')
: null,
],
/*
|--------------------------------------------------------------------------
| Contact Information
|--------------------------------------------------------------------------
|
| Default contact information used in templates and merge tags.
|
*/
'contact_email' => env('TALLCMS_CONTACT_EMAIL'),
'company_name' => env('TALLCMS_COMPANY_NAME'),
'company_address' => env('TALLCMS_COMPANY_ADDRESS'),
/*
|--------------------------------------------------------------------------
| Publishing Workflow
|--------------------------------------------------------------------------
|
| Configuration for the content publishing workflow including
| revision history and preview tokens.
|
*/
'publishing' => [
// Maximum number of automatic revisions to keep per content item.
// Set to null for unlimited. Default: 100
'revision_limit' => env('CMS_REVISION_LIMIT', 100),
// Maximum number of manual (pinned) snapshots to keep per content item.
// Set to null for unlimited. Default: 50
'revision_manual_limit' => env('CMS_REVISION_MANUAL_LIMIT', 50),
// Notification channels for workflow events
// Available: 'mail', 'database'
'notification_channels' => explode(',', env('CMS_NOTIFICATION_CHANNELS', 'mail,database')),
// Default preview token expiry in hours
'default_preview_expiry_hours' => 24,
],
/*
|--------------------------------------------------------------------------
| Plugin System
|--------------------------------------------------------------------------
|
| Configuration for the TallCMS plugin system including license management.
| The Plugin Manager UI is always available, but local plugin loading
| requires explicit opt-in via plugin_mode.plugins_enabled.
|
*/
'plugins' => [
// Path where plugins are stored
'path' => env('TALLCMS_PLUGINS_PATH', base_path('plugins')),
// Allow ZIP-based plugin uploads through admin UI
'allow_uploads' => env('TALLCMS_PLUGIN_ALLOW_UPLOADS', env('PLUGIN_ALLOW_UPLOADS', true)),
// Maximum upload size for plugin ZIP files (bytes). Default: 50MB
'max_upload_size' => env('TALLCMS_PLUGIN_MAX_UPLOAD_SIZE', env('PLUGIN_MAX_UPLOAD_SIZE', 50 * 1024 * 1024)),
// Plugin discovery caching
'cache_enabled' => env('TALLCMS_PLUGIN_CACHE_ENABLED', env('PLUGIN_CACHE_ENABLED', true)),
'cache_ttl' => 3600, // 1 hour
// Automatically run plugin migrations on install
'auto_migrate' => env('TALLCMS_PLUGIN_AUTO_MIGRATE', env('PLUGIN_AUTO_MIGRATE', true)),
// License management settings
'license' => [
// License proxy URL for official TallCMS plugins
'proxy_url' => env('TALLCMS_LICENSE_PROXY_URL', 'https://tallcms.com'),
// Cache TTL for license validation results (seconds). Default: 6 hours
'cache_ttl' => 21600,
// Grace period when license server unreachable (days). Default: 7
'offline_grace_days' => 7,
// Grace period after license expiration (days). Default: 14
'renewal_grace_days' => 14,
// How often to check for updates (seconds). Default: 24 hours
'update_check_interval' => 86400,
// Purchase URLs for plugins (shown when no license is active)
'purchase_urls' => [
'tallcms/pro' => 'https://checkout.anystack.sh/tallcms-pro-plugin',
'tallcms/mega-menu' => 'https://checkout.anystack.sh/tallcms-mega-menu-plugin',
],
// Download URLs for plugins (shown when license is valid)
'download_urls' => [
'tallcms/pro' => 'https://anystack.sh/download/tallcms-pro-plugin',
'tallcms/mega-menu' => 'https://anystack.sh/download/tallcms-mega-menu-plugin',
],
],
// Official plugin catalog (shown in Plugin Manager)
'catalog' => [
'tallcms/pro' => [
'name' => 'TallCMS Pro',
'slug' => 'pro',
'vendor' => 'tallcms',
'description' => 'Advanced blocks, analytics, and integrations for TallCMS.',
'author' => 'TallCMS',
'homepage' => 'https://tallcms.com/pro',
'icon' => 'heroicon-o-sparkles',
'category' => 'official',
'featured' => true,
'download_url' => 'https://anystack.sh/download/tallcms-pro-plugin',
'purchase_url' => 'https://checkout.anystack.sh/tallcms-pro-plugin',
],
'tallcms/mega-menu' => [
'name' => 'TallCMS Mega Menu',
'slug' => 'mega-menu',
'vendor' => 'tallcms',
'description' => 'Create stunning mega menus for your website with ease. Build rich, multi-column dropdown menus with images, icons, and custom layouts.',
'author' => 'TallCMS',
'homepage' => 'https://tallcms.com/mega-menu',
'icon' => 'heroicon-o-bars-3-bottom-left',
'category' => 'official',
'featured' => true,
'download_url' => 'https://anystack.sh/download/tallcms-mega-menu-plugin',
'purchase_url' => 'https://checkout.anystack.sh/tallcms-mega-menu-plugin',
],
],
],
/*
|--------------------------------------------------------------------------
| Theme System
|--------------------------------------------------------------------------
|
| Configuration for the TallCMS theme system. The Theme Manager UI is
| always available, but theme loading requires explicit opt-in via
| plugin_mode.themes_enabled in plugin mode.
|
*/
'themes' => [
// Path where themes are stored
'path' => env('TALLCMS_THEMES_PATH', base_path('themes')),
// Allow ZIP-based theme uploads through admin UI
'allow_uploads' => env('TALLCMS_THEME_ALLOW_UPLOADS', true),
// Maximum upload size for theme ZIP files (bytes). Default: 100MB
'max_upload_size' => env('TALLCMS_THEME_MAX_UPLOAD_SIZE', 100 * 1024 * 1024),
// Theme discovery caching
'cache_enabled' => env('TALLCMS_THEME_CACHE_ENABLED', false),
'cache_ttl' => 3600, // 1 hour
// Preview session duration (minutes)
'preview_duration' => 30,
// Rollback availability window (hours)
'rollback_duration' => 24,
],
/*
|--------------------------------------------------------------------------
| REST API
|--------------------------------------------------------------------------
|
| Configuration for the TallCMS REST API. The API provides full CRUD
| operations for Pages, Posts, Categories, and Media with authentication
| via Laravel Sanctum tokens.
|
*/
'api' => [
// Enable or disable the REST API
'enabled' => env('TALLCMS_API_ENABLED', false),
// API route prefix (e.g., 'api/v1/tallcms' results in /api/v1/tallcms/pages)
'prefix' => env('TALLCMS_API_PREFIX', 'api/v1/tallcms'),
// Standard rate limit (requests per minute)
'rate_limit' => env('TALLCMS_API_RATE_LIMIT', 60),
// Authentication rate limit (failed attempts before lockout)
'auth_rate_limit' => env('TALLCMS_API_AUTH_RATE_LIMIT', 5),
// Authentication lockout duration (minutes)
'auth_lockout_minutes' => env('TALLCMS_API_AUTH_LOCKOUT', 15),
// Default token expiry (days)
'token_expiry_days' => env('TALLCMS_API_TOKEN_EXPIRY', 365),
// Maximum items per page for pagination
'max_per_page' => 100,
],
/*
|--------------------------------------------------------------------------
| Webhooks
|--------------------------------------------------------------------------
|
| Configuration for webhook delivery to external services. Webhooks notify
| external systems when content is created, updated, published, or deleted.
|
*/
'webhooks' => [
// Enable or disable webhooks
'enabled' => env('TALLCMS_WEBHOOKS_ENABLED', false),
// Request timeout (seconds)
'timeout' => env('TALLCMS_WEBHOOK_TIMEOUT', 30),
// Maximum retry attempts
'max_retries' => env('TALLCMS_WEBHOOK_MAX_RETRIES', 3),
// Delay before retry attempts (seconds) - retry 1, 2, 3
'retry_backoff' => [60, 300, 900],
// Maximum response body size to store (bytes)
'response_max_size' => 10000,
// Allowed hosts (empty = allow all public IPs)
'allowed_hosts' => [],
// Explicitly blocked hosts
'blocked_hosts' => [],
// Queue name for webhook jobs
'queue' => env('TALLCMS_WEBHOOK_QUEUE', 'default'),
],
/*
|--------------------------------------------------------------------------
| Internationalization (i18n)
|--------------------------------------------------------------------------
|
| Core i18n configuration. Locales are merged from multiple sources:
| - Config: Base locales (always available)
| - Plugins: Can ADD new locale codes (cannot override config)
| - DB: Can MODIFY existing locales (enable/disable/rename, cannot add)
|
*/
'i18n' => [
// Master switch for multilingual features
'enabled' => env('TALLCMS_I18N_ENABLED', false),
// Base locales (always available, plugins can add new ones, DB can modify existing)
'locales' => [
'en' => [
'label' => 'English',
'native' => 'English',
'rtl' => false,
],
'zh_CN' => [
'label' => 'Chinese (Simplified)',
'native' => '简体中文',
'rtl' => false,
],
],
// Default/fallback locale (must exist in registry)
'default_locale' => env('TALLCMS_DEFAULT_LOCALE', 'en'),
// URL strategy: 'prefix' (/en/about) or 'none' (query param fallback)
'url_strategy' => 'prefix',
// Hide default locale from URL (/ instead of /en/)
'hide_default_locale' => env('TALLCMS_HIDE_DEFAULT_LOCALE', true),
// Fallback when translation missing: 'default', 'empty', 'key'
'fallback_behavior' => 'default',
// Remember locale preference in session
'remember_locale' => true,
],
/*
|--------------------------------------------------------------------------
| Comments
|--------------------------------------------------------------------------
|
| Configuration for the blog post commenting system. Comments require
| admin approval before appearing publicly.
|
*/
'comments' => [
'enabled' => env('TALLCMS_COMMENTS_ENABLED', true),
'moderation' => env('TALLCMS_COMMENTS_MODERATION', 'manual'), // 'manual' = require approval, 'auto' = publish immediately
'max_depth' => 2, // top-level + 1 reply level (min 1)
'max_length' => 5000, // max comment content length
'rate_limit' => 5, // max comments per IP per window
'rate_limit_decay' => 600, // rate limit window in seconds
'notification_channels' => ['mail', 'database'],
'notify_on_approval' => true, // email commenter when approved
'guest_comments' => true, // allow non-authenticated comments
],
/*
|--------------------------------------------------------------------------
| Media Library
|--------------------------------------------------------------------------
|
| Configuration for media library features including image optimization,
| variant generation, and responsive image handling.
|
*/
'media' => [
'optimization' => [
// Enable or disable automatic image optimization
'enabled' => env('TALLCMS_MEDIA_OPTIMIZATION', true),
// Queue name for optimization jobs
'queue' => env('TALLCMS_MEDIA_QUEUE', 'default'),
// WebP quality (0-100)
'quality' => env('TALLCMS_MEDIA_QUALITY', 80),
// Variant presets - customize sizes as needed
'variants' => [
'thumbnail' => ['width' => 300, 'height' => 300, 'fit' => 'crop'],
'medium' => ['width' => 800, 'height' => 600, 'fit' => 'contain'],
'large' => ['width' => 1200, 'height' => 800, 'fit' => 'contain'],
],
],
],
/*
|--------------------------------------------------------------------------
| Full-Text Search
|--------------------------------------------------------------------------
|
| Configuration for the full-text search functionality using Laravel Scout.
| Requires SCOUT_DRIVER=database in your .env file.
|
*/
'search' => [
// Enable or disable search functionality
'enabled' => env('TALLCMS_SEARCH_ENABLED', true),
// Minimum query length required before searching
'min_query_length' => 2,
// Number of results per page on the search results page
'results_per_page' => 10,
// Maximum results per model type to avoid memory issues
'max_results_per_type' => 50,
// Which content types to include in search
'searchable_types' => ['pages', 'posts'],
],
/*
|--------------------------------------------------------------------------
| System Updates (Standalone Mode Only)
|--------------------------------------------------------------------------
|
| Configuration for the one-click update system. These settings are
| IGNORED in plugin mode - use Composer for updates instead.
|
*/
'updates' => [
// Enable or disable the update system (standalone mode only)
'enabled' => env('TALLCMS_UPDATES_ENABLED', true),
// How often to check for updates (seconds). Default: 24 hours
'check_interval' => 86400,
// Cache TTL for GitHub API responses (seconds). Default: 1 hour
'cache_ttl' => 3600,
// GitHub repository for updates
'github_repo' => 'tallcms/tallcms',
// Optional GitHub token for higher API rate limits
'github_token' => env('TALLCMS_GITHUB_TOKEN'),
// Number of backup sets to retain
'backup_retention' => 3,
// Automatically backup files before updating
'auto_backup' => true,
// Require database backup before update
'require_db_backup' => true,
// Maximum database size for automatic backup (bytes). Default: 100MB
'db_backup_size_limit' => 100 * 1024 * 1024,
// Ed25519 public key for release signature verification (hex-encoded)
'public_key' => env('TALLCMS_UPDATE_PUBLIC_KEY', '6c41c964c60dd5341f7ba649dcda6e6de4b0b7afac2fbb9489527987907d35a9'),
],
];

View File

@ -11,13 +11,8 @@ class HomeSliderSettingsSeeder extends Seeder
public function run(): void
{
$settings = app(GeneralSettings::class);
$settings->home_slides = HomeSlideDefaults::normalize($settings->home_slides ?? []);
$settings->home_slides = HomeSlideDefaults::defaults();
$settings->save();
}
private function defaultHomeSlides(): array
{
return HomeSlideDefaults::defaults();
}
}

View File

@ -1,22 +1,22 @@
<?php
return [
'site_name' => 'OpenClassify',
'home' => 'Ana Sayfa',
'categories' => 'Kategoriler',
'listings' => 'İlanlar',
'search' => 'Ara',
'search_placeholder' => 'Her şeyi arayın...',
'login' => 'Giriş',
'register' => 'Kayıt Ol',
'logout' => 'Çıkış',
'find_what_you_need' => 'İhtiyacınızı Bulun',
'hero_subtitle' => 'Bölgenizdeki her şeyi alın ve satın',
'browse_categories' => 'Kategorilere Göz At',
'recent_listings' => 'Son İlanlar',
'featured_listings' => 'Öne Çıkan İlanlar',
'post_listing' => 'İlan Ver',
'sell_something' => 'Satılık bir şeyiniz mi var?',
'free' => 'Ücretsiz',
'view' => 'Görüntüle',
'contact_seller' => 'Satıcıyla İletişim',
'home' => 'Home',
'categories' => 'Categories',
'listings' => 'Listings',
'search' => 'Search',
'search_placeholder' => 'Search for anything...',
'login' => 'Login',
'register' => 'Register',
'logout' => 'Logout',
'find_what_you_need' => 'Find What You Need',
'hero_subtitle' => 'Buy and sell everything in your area',
'browse_categories' => 'Browse Categories',
'recent_listings' => 'Recent Listings',
'featured_listings' => 'Featured Listings',
'post_listing' => 'Post Listing',
'sell_something' => 'Have something to sell?',
'free' => 'Free',
'view' => 'View',
'contact_seller' => 'Contact Seller',
];

View File

@ -0,0 +1,22 @@
<svg width="1600" height="1000" viewBox="0 0 1600 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1600" height="1000" rx="48" fill="#F4F7FB"/>
<rect x="70" y="70" width="1460" height="860" rx="40" fill="url(#bg)"/>
<circle cx="1280" cy="220" r="180" fill="#E4ECF9"/>
<rect x="170" y="156" width="310" height="56" rx="28" fill="#FFFFFF"/>
<rect x="170" y="256" width="420" height="76" rx="22" fill="#0F172A" fill-opacity="0.08"/>
<rect x="170" y="358" width="500" height="34" rx="17" fill="#0F172A" fill-opacity="0.08"/>
<rect x="170" y="410" width="430" height="34" rx="17" fill="#0F172A" fill-opacity="0.06"/>
<rect x="170" y="500" width="150" height="58" rx="29" fill="#0F172A"/>
<rect x="780" y="196" width="600" height="610" rx="40" fill="#FFFFFF"/>
<rect x="830" y="256" width="220" height="184" rx="28" fill="#E0ECFF"/>
<rect x="1070" y="256" width="220" height="184" rx="28" fill="#E8EEF8"/>
<rect x="830" y="466" width="220" height="184" rx="28" fill="#EEF4FF"/>
<rect x="1070" y="466" width="220" height="184" rx="28" fill="#DCE8FF"/>
<rect x="876" y="678" width="368" height="22" rx="11" fill="#CBD5E1"/>
<defs>
<linearGradient id="bg" x1="70" y1="70" x2="1530" y2="930" gradientUnits="userSpaceOnUse">
<stop stop-color="#FBFDFF"/>
<stop offset="1" stop-color="#E8EFF8"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,20 @@
<svg width="1600" height="1000" viewBox="0 0 1600 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1600" height="1000" rx="48" fill="#EEF5FF"/>
<rect x="70" y="70" width="1460" height="860" rx="40" fill="url(#bg)"/>
<circle cx="1220" cy="210" r="170" fill="#D7E7FF"/>
<rect x="180" y="170" width="280" height="56" rx="28" fill="#FFFFFF"/>
<rect x="180" y="258" width="520" height="76" rx="22" fill="#0F172A" fill-opacity="0.08"/>
<rect x="180" y="360" width="470" height="34" rx="17" fill="#0F172A" fill-opacity="0.08"/>
<rect x="180" y="412" width="390" height="34" rx="17" fill="#0F172A" fill-opacity="0.06"/>
<rect x="180" y="500" width="180" height="60" rx="30" fill="#0F172A"/>
<rect x="840" y="214" width="440" height="520" rx="40" fill="#FFFFFF"/>
<path d="M1060 318C996 318 944 370 944 434C944 524 1060 640 1060 640C1060 640 1176 524 1176 434C1176 370 1124 318 1060 318Z" fill="#DBEAFE"/>
<circle cx="1060" cy="434" r="54" fill="#93C5FD"/>
<rect x="894" y="766" width="332" height="22" rx="11" fill="#CBD5E1"/>
<defs>
<linearGradient id="bg" x1="70" y1="70" x2="1530" y2="930" gradientUnits="userSpaceOnUse">
<stop stop-color="#F8FBFF"/>
<stop offset="1" stop-color="#DEEAFF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,24 @@
<svg width="1600" height="1000" viewBox="0 0 1600 1000" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="1600" height="1000" rx="48" fill="#E9F1FF"/>
<rect x="70" y="70" width="1460" height="860" rx="40" fill="url(#bg)"/>
<circle cx="1230" cy="250" r="210" fill="#C9DCFF"/>
<circle cx="1360" cy="170" r="90" fill="#F8FBFF"/>
<rect x="180" y="170" width="520" height="74" rx="20" fill="#0F172A" fill-opacity="0.08"/>
<rect x="180" y="276" width="620" height="36" rx="18" fill="#0F172A" fill-opacity="0.08"/>
<rect x="180" y="332" width="560" height="36" rx="18" fill="#0F172A" fill-opacity="0.06"/>
<rect x="180" y="430" width="176" height="64" rx="32" fill="#0F172A"/>
<rect x="376" y="430" width="176" height="64" rx="32" fill="#F8FBFF"/>
<rect x="930" y="250" width="360" height="490" rx="48" fill="#111827"/>
<rect x="958" y="282" width="304" height="426" rx="32" fill="#F8FAFC"/>
<rect x="994" y="332" width="232" height="160" rx="28" fill="#D9E7FF"/>
<rect x="994" y="522" width="168" height="18" rx="9" fill="#CBD5E1"/>
<rect x="994" y="560" width="216" height="18" rx="9" fill="#E2E8F0"/>
<rect x="994" y="608" width="210" height="68" rx="24" fill="#FFFFFF"/>
<rect x="1180" y="608" width="46" height="68" rx="23" fill="#DBEAFE"/>
<defs>
<linearGradient id="bg" x1="70" y1="70" x2="1530" y2="930" gradientUnits="userSpaceOnUse">
<stop stop-color="#F8FBFF"/>
<stop offset="1" stop-color="#D8E7FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -84,6 +84,178 @@ h6 {
border-bottom: 1px solid var(--oc-border);
}
.oc-nav-wrap {
max-width: 1320px;
margin: 0 auto;
padding: 18px 16px 14px;
}
.oc-nav-main {
display: grid;
grid-template-columns: auto minmax(320px, 1fr) auto;
align-items: center;
gap: 18px;
}
.oc-brand {
display: inline-flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.oc-search {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
padding: 0 14px 0 18px;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
}
.oc-search-icon {
color: #6e6e73;
flex-shrink: 0;
}
.oc-search-input {
width: 100%;
border: 0;
background: transparent;
color: var(--oc-text);
font-size: 1rem;
line-height: 1.4;
outline: none;
}
.oc-search-input::placeholder {
color: #8d8d92;
}
.oc-search-submit {
border: 0;
background: transparent;
color: #4b5563;
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
cursor: pointer;
}
.oc-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.oc-pill {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 0 18px;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.84);
color: #4b5563;
font-size: 0.95rem;
font-weight: 500;
}
.oc-pill-strong {
color: #fff;
background: linear-gradient(180deg, #2997ff, var(--oc-primary));
border-color: transparent;
box-shadow: 0 10px 22px rgba(0, 113, 227, 0.18);
}
.oc-cta {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 0 22px;
font-size: 0.96rem;
font-weight: 600;
}
.oc-text-link {
color: #6e6e73;
font-size: 0.95rem;
font-weight: 500;
transition: color 0.2s ease;
}
.oc-text-link:hover {
color: var(--oc-text);
}
.oc-mobile-tools {
display: grid;
gap: 12px;
margin-top: 14px;
}
.oc-mobile-pills {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 2px;
}
.oc-category-row {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid rgba(29, 29, 31, 0.08);
}
.oc-category-track {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.oc-category-pill {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 0 18px;
border-radius: 999px;
border: 1px solid rgba(29, 29, 31, 0.08);
background: rgba(255, 255, 255, 0.84);
color: var(--oc-text);
font-size: 0.95rem;
font-weight: 600;
white-space: nowrap;
}
.oc-category-link {
display: inline-flex;
align-items: center;
min-height: 46px;
padding: 0 18px;
border-radius: 999px;
color: #4b5563;
font-size: 0.95rem;
font-weight: 500;
white-space: nowrap;
transition: background 0.2s ease, color 0.2s ease;
}
.oc-category-link:hover,
.oc-category-pill:hover,
.oc-pill:hover {
background: rgba(255, 255, 255, 0.98);
color: var(--oc-text);
}
.search-shell {
border: 1px solid var(--oc-border);
background: #ffffff;
@ -140,6 +312,58 @@ h6 {
font-size: 0.875rem;
}
@media (max-width: 1279px) {
.oc-nav-main {
grid-template-columns: auto minmax(0, 1fr);
}
.oc-actions {
grid-column: 1 / -1;
justify-content: space-between;
flex-wrap: wrap;
}
}
@media (max-width: 1023px) {
.oc-nav-wrap {
padding-top: 14px;
padding-bottom: 12px;
}
.oc-nav-main {
grid-template-columns: 1fr auto;
gap: 12px;
}
.brand-text {
font-size: 1.42rem;
}
.oc-actions {
grid-column: auto;
justify-content: flex-end;
}
}
@media (max-width: 767px) {
.oc-nav-main {
grid-template-columns: 1fr;
}
.oc-brand {
justify-content: center;
}
.oc-actions {
display: none;
}
.oc-category-row {
margin-top: 12px;
padding-top: 12px;
}
}
summary::-webkit-details-marker {
display: none;
}

View File

@ -15,6 +15,13 @@
<form method="POST" action="{{ route('login') }}">
@csrf
@php
$redirectInput = old('redirect', $redirectTo ?? request('redirect'));
@endphp
@if(filled($redirectInput))
<input type="hidden" name="redirect" value="{{ $redirectInput }}">
@endif
<!-- Email Address -->
<div>

View File

@ -13,13 +13,21 @@
$subtitle = trim((string) ($slide['subtitle'] ?? ''));
$primaryButtonText = trim((string) ($slide['primary_button_text'] ?? ''));
$secondaryButtonText = trim((string) ($slide['secondary_button_text'] ?? ''));
$imagePath = trim((string) ($slide['image_path'] ?? ''));
return [
'badge' => $badge !== '' ? $badge : 'OpenClassify Marketplace',
'title' => $title !== '' ? $title : 'İlan ücreti ödemeden ürününü hızla sat!',
'title' => $title !== '' ? $title : 'Sell faster with a cleaner local marketplace.',
'subtitle' => $subtitle !== '' ? $subtitle : 'Buy and sell everything in your area',
'primary_button_text' => $primaryButtonText !== '' ? $primaryButtonText : 'İncele',
'primary_button_text' => $primaryButtonText !== '' ? $primaryButtonText : 'Browse Listings',
'secondary_button_text' => $secondaryButtonText !== '' ? $secondaryButtonText : 'Post Listing',
'image_url' => $imagePath !== ''
? (str_starts_with($imagePath, 'http://') || str_starts_with($imagePath, 'https://')
? $imagePath
: (str_starts_with($imagePath, 'images/')
? asset($imagePath)
: \Illuminate\Support\Facades\Storage::disk('public')->url($imagePath)))
: null,
];
})
->values();
@ -28,10 +36,11 @@
$homeSlides = collect([
[
'badge' => 'OpenClassify Marketplace',
'title' => 'İlan ücreti ödemeden ürününü hızla sat!',
'title' => 'Sell faster with a cleaner local marketplace.',
'subtitle' => 'Buy and sell everything in your area',
'primary_button_text' => 'İncele',
'primary_button_text' => 'Browse Listings',
'secondary_button_text' => 'Post Listing',
'image_url' => null,
],
]);
}
@ -134,7 +143,7 @@
<div class="w-full h-full rounded-[24px] bg-white overflow-hidden">
<div class="px-3 py-2 border-b border-slate-100">
<p class="text-rose-500 text-sm font-bold">OpenClassify</p>
<p class="text-[10px] text-slate-400 mt-1">Ürün, kategori, satıcı ara</p>
<p class="text-[10px] text-slate-400 mt-1">Search listings, categories, and sellers</p>
</div>
<div class="p-2 space-y-2">
<div class="h-10 rounded-xl bg-slate-100"></div>
@ -150,15 +159,25 @@
</div>
</div>
</div>
<div class="absolute right-0 bottom-0 w-[78%] h-[88%] rounded-[28px] bg-gradient-to-br from-white/20 to-blue-500/40 border border-white/20 shadow-2xl flex items-end justify-center p-4">
@if($heroImage)
<img src="{{ $heroImage }}" alt="{{ $heroListing?->title }}" class="w-full h-full object-cover rounded-2xl">
@else
<div class="w-full h-full rounded-2xl bg-white/90 text-slate-800 flex flex-col justify-center items-center gap-3">
<span class="text-6xl">🚗</span>
<p class="text-sm font-semibold px-4 text-center">Görsel eklendiğinde burada öne çıkan ilan yer alacak.</p>
<div class="absolute right-0 bottom-0 w-[78%] h-[88%] rounded-[28px] bg-gradient-to-br from-white/20 to-blue-500/40 border border-white/20 shadow-2xl flex items-end justify-center p-4 overflow-hidden">
@foreach($homeSlides as $index => $slide)
<div
data-home-slide-visual
@class(['absolute inset-4 transition-opacity duration-300', 'hidden' => $index !== 0])
aria-hidden="{{ $index === 0 ? 'false' : 'true' }}"
>
@if($slide['image_url'])
<img src="{{ $slide['image_url'] }}" alt="{{ $slide['title'] }}" class="w-full h-full object-cover rounded-2xl">
@elseif($heroImage)
<img src="{{ $heroImage }}" alt="{{ $heroListing?->title }}" class="w-full h-full object-cover rounded-2xl">
@else
<div class="w-full h-full rounded-2xl bg-white/90 text-slate-800 flex flex-col justify-center items-center gap-3">
<span class="text-6xl"></span>
<p class="text-sm font-semibold px-4 text-center">Upload a slide image to make this area feel complete.</p>
</div>
@endif
</div>
@endif
@endforeach
</div>
</div>
</div>
@ -375,6 +394,7 @@
}
const slides = Array.from(slider.querySelectorAll('[data-home-slide]'));
const visuals = Array.from(document.querySelectorAll('[data-home-slide-visual]'));
const dots = Array.from(slider.querySelectorAll('[data-home-slide-dot]'));
const previousButton = slider.querySelector('[data-home-slide-prev]');
const nextButton = slider.querySelector('[data-home-slide-next]');
@ -396,6 +416,13 @@
slide.setAttribute('aria-hidden', isActive ? 'false' : 'true');
});
visuals.forEach((visual, visualIndex) => {
const isActive = visualIndex === activeIndex;
visual.classList.toggle('hidden', !isActive);
visual.setAttribute('aria-hidden', isActive ? 'false' : 'true');
});
dots.forEach((dot, dotIndex) => {
const isActive = dotIndex === activeIndex;

View File

@ -46,19 +46,19 @@
</head>
<body class="min-h-screen font-sans antialiased">
<nav class="market-nav-surface sticky top-0 z-50">
<div class="max-w-[1320px] mx-auto px-4 py-4">
<div class="flex items-center gap-3 md:gap-4">
<a href="{{ route('home') }}" class="shrink-0 flex items-center gap-2.5">
<div class="oc-nav-wrap">
<div class="oc-nav-main">
<a href="{{ route('home') }}" class="oc-brand">
@if($siteLogoUrl)
<img src="{{ $siteLogoUrl }}" alt="{{ $siteName }}" class="h-9 w-auto rounded">
<img src="{{ $siteLogoUrl }}" alt="{{ $siteName }}" class="h-9 w-auto rounded-xl">
@else
<span class="brand-logo" aria-hidden="true"></span>
@endif
<span class="brand-text leading-none">{{ $siteName }}</span>
</a>
<form action="{{ route('listings.index') }}" method="GET" class="hidden lg:flex flex-1 search-shell items-center gap-2 px-4 py-2.5">
<svg class="w-5 h-5 text-rose-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<form action="{{ route('listings.index') }}" method="GET" class="oc-search hidden lg:flex">
<svg class="w-5 h-5 oc-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M21 21l-4.35-4.35m1.6-5.05a7.25 7.25 0 11-14.5 0 7.25 7.25 0 0114.5 0z"/>
</svg>
<input
@ -66,57 +66,57 @@
name="search"
value="{{ request('search') }}"
placeholder="{{ __('messages.search_placeholder') }}"
class="w-full bg-transparent text-sm text-slate-700 placeholder:text-slate-400 focus:outline-none"
class="oc-search-input"
>
<button type="submit" class="text-xs font-semibold text-slate-500 hover:text-slate-700 transition">
<button type="submit" class="oc-search-submit">
{{ __('messages.search') }}
</button>
</form>
<details class="relative hidden md:block" data-location-widget data-cities-url-template="{{ $citiesRouteTemplate }}">
<summary class="chip-btn list-none cursor-pointer px-4 py-2.5 text-sm text-slate-700 inline-flex items-center gap-2">
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 21s7-6.2 7-11a7 7 0 10-14 0c0 4.8 7 11 7 11z"/>
<circle cx="12" cy="10" r="2.3" stroke-width="1.8" />
</svg>
<span data-location-label class="max-w-44 truncate">Choose location</span>
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M6 9l6 6 6-6"/>
</svg>
</summary>
<div class="location-panel absolute right-0 mt-2 bg-white border border-slate-200 shadow-xl rounded-2xl p-4 space-y-3">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-slate-900">Location</p>
<button type="button" data-location-detect class="text-xs font-semibold text-rose-500 hover:text-rose-600 transition">Use my location</button>
<div class="oc-actions">
<details class="relative hidden md:block" data-location-widget data-cities-url-template="{{ $citiesRouteTemplate }}">
<summary class="oc-pill list-none cursor-pointer">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 21s7-6.2 7-11a7 7 0 10-14 0c0 4.8 7 11 7 11z"/>
<circle cx="12" cy="10" r="2.3" stroke-width="1.8" />
</svg>
<span data-location-label class="max-w-40 truncate">Choose location</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M6 9l6 6 6-6"/>
</svg>
</summary>
<div class="location-panel absolute right-0 top-full mt-3 bg-white border border-slate-200 shadow-xl rounded-2xl p-4 space-y-3">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-slate-900">Location</p>
<button type="button" data-location-detect class="text-xs font-semibold text-slate-600 hover:text-slate-900 transition">Use my location</button>
</div>
<p data-location-status class="text-xs text-slate-500">Auto-select country and city from your browser location.</p>
<div class="space-y-2">
<label class="block text-xs font-semibold text-slate-600">Country</label>
<select data-location-country class="w-full">
<option value="">Select country</option>
@foreach($locationCountries as $country)
<option
value="{{ $country['id'] }}"
data-code="{{ strtoupper($country['code'] ?? '') }}"
data-name="{{ $country['name'] }}"
data-default="{{ strtoupper($country['code'] ?? '') === $defaultCountryIso2 ? '1' : '0' }}"
>
{{ $country['name'] }}
</option>
@endforeach
</select>
</div>
<div class="space-y-2">
<label class="block text-xs font-semibold text-slate-600">City</label>
<select data-location-city class="w-full" disabled>
<option value="">Select country first</option>
</select>
</div>
<button type="button" data-location-save class="w-full btn-primary px-4 py-2.5 text-sm font-semibold transition">Apply</button>
</div>
<p data-location-status class="text-xs text-slate-500">Auto-select country and city from your browser location.</p>
<div class="space-y-2">
<label class="block text-xs font-semibold text-slate-600">Country</label>
<select data-location-country class="w-full">
<option value="">Select country</option>
@foreach($locationCountries as $country)
<option
value="{{ $country['id'] }}"
data-code="{{ strtoupper($country['code'] ?? '') }}"
data-name="{{ $country['name'] }}"
data-default="{{ strtoupper($country['code'] ?? '') === $defaultCountryIso2 ? '1' : '0' }}"
>
{{ $country['name'] }}
</option>
@endforeach
</select>
</div>
<div class="space-y-2">
<label class="block text-xs font-semibold text-slate-600">City</label>
<select data-location-city class="w-full" disabled>
<option value="">Select country first</option>
</select>
</div>
<button type="button" data-location-save class="w-full btn-primary px-4 py-2.5 text-sm font-semibold hover:brightness-95 transition">Apply</button>
</div>
</details>
</details>
<div class="ml-auto flex items-center gap-2 md:gap-3">
@auth
<a href="{{ $favoritesRoute }}" class="header-utility hidden xl:inline-flex" aria-label="Favorites">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -134,27 +134,27 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M3 12l9-9 9 9M5 10v10h14V10"/>
</svg>
</a>
<a href="{{ $panelCreateRoute }}" class="btn-primary px-4 md:px-5 py-2.5 text-sm font-semibold shadow-sm hover:brightness-95 transition">
<a href="{{ $panelCreateRoute }}" class="btn-primary oc-cta">
Sell
</a>
<form method="POST" action="{{ $logoutRoute }}" class="hidden xl:block">
@csrf
<button type="submit" class="text-sm text-slate-500 hover:text-rose-500 transition">{{ __('messages.logout') }}</button>
<button type="submit" class="oc-text-link">{{ __('messages.logout') }}</button>
</form>
@else
<a href="{{ $loginRoute }}" class="bg-rose-50 text-rose-500 px-4 md:px-5 py-2.5 rounded-full text-sm font-semibold hover:bg-rose-100 transition">
<a href="{{ $loginRoute }}" class="oc-text-link hidden md:inline-flex">
{{ __('messages.login') }}
</a>
<a href="{{ $panelCreateRoute }}" class="btn-primary px-4 md:px-5 py-2.5 text-sm font-semibold shadow-sm hover:brightness-95 transition">
<a href="{{ $panelCreateRoute }}" class="btn-primary oc-cta">
Sell
</a>
@endauth
</div>
</div>
<div class="mt-3 space-y-2 lg:hidden">
<form action="{{ route('listings.index') }}" method="GET" class="search-shell flex items-center gap-2 px-3 py-2.5">
<svg class="w-4 h-4 text-rose-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="oc-mobile-tools lg:hidden">
<form action="{{ route('listings.index') }}" method="GET" class="oc-search">
<svg class="w-5 h-5 oc-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M21 21l-4.35-4.35m1.6-5.05a7.25 7.25 0 11-14.5 0 7.25 7.25 0 0114.5 0z"/>
</svg>
<input
@ -162,31 +162,32 @@
name="search"
value="{{ request('search') }}"
placeholder="{{ __('messages.search_placeholder') }}"
class="w-full bg-transparent text-sm text-slate-700 placeholder:text-slate-400 focus:outline-none"
class="oc-search-input"
>
<button type="submit" class="text-xs text-slate-500">{{ __('messages.search') }}</button>
<button type="submit" class="oc-search-submit">{{ __('messages.search') }}</button>
</form>
<div class="flex items-center gap-2 overflow-x-auto pb-1">
<span class="chip-btn whitespace-nowrap px-4 py-2 text-sm text-slate-700" data-location-label-mobile>Choose location</span>
<a href="{{ $panelCreateRoute }}" class="chip-btn whitespace-nowrap px-4 py-2 text-sm text-rose-600 font-semibold">Sell</a>
<div class="oc-mobile-pills">
<span class="oc-pill" data-location-label-mobile>Choose location</span>
<a href="{{ $panelCreateRoute }}" class="oc-pill oc-pill-strong">Sell</a>
</div>
</div>
<div class="mt-4 border-t border-slate-200 pt-3 overflow-x-auto">
<div class="flex items-center gap-2 min-w-max pb-1">
<a href="{{ route('categories.index') }}" class="chip-btn inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-100 transition">
<div class="oc-category-row">
<div class="oc-category-track">
<a href="{{ route('categories.index') }}" class="oc-category-pill">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
All Categories
<span>All Categories</span>
</a>
@forelse($headerCategories as $headerCategory)
<a href="{{ route('listings.index', ['category' => $headerCategory['id']]) }}" class="px-4 py-2.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 transition whitespace-nowrap">
<a href="{{ route('listings.index', ['category' => $headerCategory['id']]) }}" class="oc-category-link">
{{ $headerCategory['name'] }}
</a>
@empty
<a href="{{ route('home') }}" class="chip-btn whitespace-nowrap px-4 py-2 hover:bg-slate-100 transition">{{ __('messages.home') }}</a>
<a href="{{ route('listings.index') }}" class="chip-btn whitespace-nowrap px-4 py-2 hover:bg-slate-100 transition">{{ __('messages.listings') }}</a>
<a href="{{ route('home') }}" class="oc-category-link">{{ __('messages.home') }}</a>
<a href="{{ route('listings.index') }}" class="oc-category-link">{{ __('messages.listings') }}</a>
@endforelse
</div>
</div>
@ -291,7 +292,7 @@
const formatLocationLabel = (location) => {
if (!location || typeof location !== 'object') {
return 'Konum seç';
return 'Choose location';
}
const cityName = (location.cityName ?? '').toString().trim();
@ -305,7 +306,7 @@
return countryName;
}
return 'Konum seç';
return 'Choose location';
};
const updateLabels = (location) => {
@ -372,13 +373,13 @@
}
if (normalizedCountryId === '' || template === '') {
citySelect.innerHTML = '<option value="">Önce ülke seç</option>';
citySelect.innerHTML = '<option value="">Select country first</option>';
citySelect.disabled = true;
return;
}
citySelect.disabled = true;
citySelect.innerHTML = '<option value="">Şehir yükleniyor...</option>';
citySelect.innerHTML = '<option value="">Loading cities...</option>';
try {
const primaryUrl = buildCitiesUrl(template, normalizedCountryId);
@ -412,10 +413,10 @@
cityOptions = await fetchCityOptions(fallbackUrl);
}
citySelect.innerHTML = '<option value="">Şehir seç</option>';
citySelect.innerHTML = '<option value="">Select city</option>';
if (cityOptions.length === 0) {
citySelect.innerHTML = '<option value="">Şehir bulunamadı</option>';
citySelect.innerHTML = '<option value="">No cities found</option>';
citySelect.disabled = true;
return;
}
@ -439,21 +440,55 @@
}
}
} catch (error) {
citySelect.innerHTML = '<option value="">Şehir yüklenemedi</option>';
citySelect.innerHTML = '<option value="">Could not load cities</option>';
citySelect.disabled = true;
if (statusText) {
statusText.textContent = 'Şehir listesi alınamadı. Lütfen tekrar deneyin.';
statusText.textContent = 'Could not load the city list. Please try again.';
}
}
};
const findMatchingCityOption = (citySelect, candidates) => {
const normalizedCandidates = candidates
.map((candidate) => normalize(candidate))
.filter((candidate) => candidate !== '');
if (normalizedCandidates.length === 0) {
return null;
}
const options = Array.from(citySelect.options).filter((option) => option.value !== '');
for (const candidate of normalizedCandidates) {
const exactMatch = options.find((option) => normalize(option.dataset.name || option.textContent) === candidate);
if (exactMatch) {
return exactMatch;
}
}
for (const candidate of normalizedCandidates) {
const containsMatch = options.find((option) => {
const optionName = normalize(option.dataset.name || option.textContent);
return optionName.includes(candidate) || candidate.includes(optionName);
});
if (containsMatch) {
return containsMatch;
}
}
return null;
};
const saveFromInputs = (root, extra = {}) => {
const countrySelect = root.querySelector('[data-location-country]');
const citySelect = root.querySelector('[data-location-city]');
const details = root.closest('details');
if (!countrySelect || !citySelect || !countrySelect.value) {
return;
return false;
}
const countryOption = countrySelect.options[countrySelect.selectedIndex];
@ -476,6 +511,8 @@
if (details && details.hasAttribute('open')) {
details.removeAttribute('open');
}
return true;
};
const reverseLookup = async (latitude, longitude) => {
@ -502,7 +539,9 @@
return {
countryCode: (address.country_code ?? '').toUpperCase(),
countryName: address.country ?? '',
cityName: address.city ?? address.town ?? address.village ?? address.municipality ?? address.state_district ?? address.state ?? '',
cityName: address.city ?? address.town ?? address.village ?? address.municipality ?? '',
regionName: address.state ?? address.province ?? '',
districtName: address.state_district ?? address.county ?? '',
};
};
@ -574,25 +613,26 @@
countrySelect.addEventListener('change', async () => {
if (statusText) {
statusText.textContent = 'Ülkeye göre şehirler güncelleniyor...';
statusText.textContent = 'Updating cities for the selected country...';
}
await loadCities(root, countrySelect.value, null, null);
if (statusText) {
statusText.textContent = 'Şehir seçimini tamamlayıp uygulayabilirsiniz.';
statusText.textContent = 'Select a city and apply.';
}
});
saveButton.addEventListener('click', () => {
saveFromInputs(root);
if (statusText) {
statusText.textContent = 'Konum kaydedildi.';
const saved = saveFromInputs(root);
if (saved && statusText) {
statusText.textContent = 'Location saved.';
}
});
if (detectButton) {
detectButton.addEventListener('click', async () => {
if (statusText) {
statusText.textContent = 'Konumunuz alınıyor...';
statusText.textContent = 'Getting your location...';
}
try {
@ -609,23 +649,47 @@
if (!matchedCountry) {
if (statusText) {
statusText.textContent = 'Ülke eşleşmesi bulunamadı, lütfen manuel seçim yapın.';
statusText.textContent = 'No matching country found. Please choose it manually.';
}
return;
}
countrySelect.value = matchedCountry.value;
await loadCities(root, matchedCountry.value, null, guessed.cityName);
saveFromInputs(root, { latitude, longitude });
await loadCities(root, matchedCountry.value, null, null);
if (statusText) {
statusText.textContent = 'Konum otomatik seçildi.';
const matchedCity = findMatchingCityOption(citySelect, [
guessed.cityName,
guessed.regionName,
guessed.districtName,
]);
if (matchedCity) {
citySelect.value = matchedCity.value;
}
if (!matchedCity && !citySelect.disabled && citySelect.options.length > 1) {
if (statusText) {
statusText.textContent = 'Country was selected, but the city could not be matched automatically. Please choose your city.';
}
const details = root.closest('details');
if (details) {
details.setAttribute('open', 'open');
}
return;
}
const saved = saveFromInputs(root, { latitude, longitude });
if (saved && statusText) {
statusText.textContent = 'Location selected automatically.';
}
} catch (error) {
if (statusText) {
statusText.textContent = error?.message === 'secure_context_required'
? 'Tarayıcı konumu için HTTPS gerekli. Lütfen siteyi güvenli bağlantıdan açın.'
: 'Konum alınamadı. Tarayıcı izinlerini kontrol edin.';
? 'HTTPS is required for browser location. Open the site over a secure connection.'
: 'Could not access location. Check your browser permissions.';
}
}
});