Refactor modules for mobile UI

This commit is contained in:
fatihalp 2026-03-09 00:08:58 +03:00
parent e601c3dd9f
commit de09a50893
27 changed files with 510 additions and 477 deletions

View File

@ -3,12 +3,12 @@
namespace Modules\Conversation\Database\Seeders; namespace Modules\Conversation\Database\Seeders;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Modules\Conversation\App\Models\Conversation; use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Models\ConversationMessage; use Modules\Conversation\App\Models\ConversationMessage;
use Modules\Listing\Models\Listing; use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User; use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class ConversationDemoSeeder extends Seeder class ConversationDemoSeeder extends Seeder
{ {
@ -18,73 +18,60 @@ class ConversationDemoSeeder extends Seeder
return; return;
} }
$admin = User::query()->where('email', 'a@a.com')->first(); $users = User::query()
$member = User::query()->where('email', 'b@b.com')->first(); ->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
->get()
->values();
if (! $admin || ! $member) { if ($users->count() < 2) {
return; return;
} }
$adminListings = Listing::query() ConversationMessage::query()
->where('user_id', $admin->getKey()) ->whereHas('conversation', fn ($query) => $query->whereIn('buyer_id', $users->pluck('id'))->orWhereIn('seller_id', $users->pluck('id')))
->where('status', 'active') ->delete();
->orderBy('id')
->take(2)
->get();
$memberListings = Listing::query() Conversation::query()
->where('user_id', $member->getKey()) ->whereIn('buyer_id', $users->pluck('id'))
->where('status', 'active') ->orWhereIn('seller_id', $users->pluck('id'))
->orderBy('id') ->delete();
->take(2)
->get();
if ($adminListings->count() < 2 || $memberListings->count() < 2) { foreach ($users as $index => $buyer) {
return; $primarySeller = $users->get(($index + 1) % $users->count());
$secondarySeller = $users->get(($index + 2) % $users->count());
if (! $primarySeller instanceof User || ! $secondarySeller instanceof User) {
continue;
} }
$primaryListing = Listing::query()
->where('user_id', $primarySeller->getKey())
->where('status', 'active')
->orderBy('id')
->first();
$secondaryListing = Listing::query()
->where('user_id', $secondarySeller->getKey())
->where('status', 'active')
->orderBy('id')
->first();
$this->seedConversationThread( $this->seedConversationThread(
$admin, $primarySeller,
$member, $buyer,
$adminListings->get(0), $primaryListing,
[ $this->messagePayloads($index, false)
['sender' => 'buyer', 'body' => 'Hi, is this still available?', 'hours_ago' => 30, 'read_after_minutes' => 4],
['sender' => 'seller', 'body' => 'Yes, it is. I can share more photos.', 'hours_ago' => 29, 'read_after_minutes' => 7],
['sender' => 'buyer', 'body' => 'Perfect. Can we meet tomorrow afternoon?', 'hours_ago' => 4, 'read_after_minutes' => null],
]
); );
$this->seedConversationThread( $this->seedConversationThread(
$admin, $secondarySeller,
$member, $buyer,
$adminListings->get(1), $secondaryListing,
[ $this->messagePayloads($index, true)
['sender' => 'buyer', 'body' => 'Can you confirm the final price?', 'hours_ago' => 20, 'read_after_minutes' => 8],
['sender' => 'seller', 'body' => 'I can do a small discount if you pick it up today.', 'hours_ago' => 18, 'read_after_minutes' => null],
]
);
$this->seedConversationThread(
$member,
$admin,
$memberListings->get(0),
[
['sender' => 'buyer', 'body' => 'Hello, does this listing include the original accessories?', 'hours_ago' => 14, 'read_after_minutes' => 6],
['sender' => 'seller', 'body' => 'Yes, box and accessories are included.', 'hours_ago' => 13, 'read_after_minutes' => 9],
['sender' => 'buyer', 'body' => 'Great. I can pick it up tonight.', 'hours_ago' => 2, 'read_after_minutes' => null],
]
);
$this->seedConversationThread(
$member,
$admin,
$memberListings->get(1),
[
['sender' => 'buyer', 'body' => 'Would you accept a bank transfer?', 'hours_ago' => 11, 'read_after_minutes' => 5],
['sender' => 'seller', 'body' => 'Yes, that works for me.', 'hours_ago' => 10, 'read_after_minutes' => null],
]
); );
} }
}
private function conversationTablesExist(): bool private function conversationTablesExist(): bool
{ {
@ -97,7 +84,7 @@ class ConversationDemoSeeder extends Seeder
?Listing $listing, ?Listing $listing,
array $messages array $messages
): void { ): void {
if (! $listing) { if (! $listing || (int) $seller->getKey() === (int) $buyer->getKey()) {
return; return;
} }
@ -112,10 +99,6 @@ class ConversationDemoSeeder extends Seeder
] ]
); );
ConversationMessage::query()
->where('conversation_id', $conversation->getKey())
->delete();
$lastMessageAt = null; $lastMessageAt = null;
foreach ($messages as $payload) { foreach ($messages as $payload) {
@ -137,10 +120,47 @@ class ConversationDemoSeeder extends Seeder
$lastMessageAt = $createdAt; $lastMessageAt = $createdAt;
} }
if ($lastMessageAt) {
$conversation->forceFill([ $conversation->forceFill([
'seller_id' => $seller->getKey(), 'seller_id' => $seller->getKey(),
'last_message_at' => $lastMessageAt, 'last_message_at' => $lastMessageAt,
'updated_at' => $lastMessageAt, 'updated_at' => $lastMessageAt,
])->saveQuietly(); ])->saveQuietly();
} }
}
private function messagePayloads(int $index, bool $secondary): array
{
$openingMessages = [
'Is this listing still available?',
'Can you share the best price?',
'Would pickup this evening work for you?',
'Can you confirm the condition details?',
'Do you have any more photos?',
];
$sellerReplies = [
'Yes, it is available.',
'I can offer a small discount.',
'This evening works for me.',
'Everything is in clean condition.',
'I can send more photos in a minute.',
];
$closingMessages = [
'Great, I will message again before I leave.',
'Perfect. I can arrange pickup.',
'Thanks. That sounds good to me.',
'Understood. I am interested.',
'Nice. I will keep this saved.',
];
$offset = ($index + ($secondary ? 2 : 0)) % count($openingMessages);
return [
['sender' => 'buyer', 'body' => $openingMessages[$offset], 'hours_ago' => 30 - $index, 'read_after_minutes' => 5],
['sender' => 'seller', 'body' => $sellerReplies[$offset], 'hours_ago' => 28 - $index, 'read_after_minutes' => 8],
['sender' => 'buyer', 'body' => $closingMessages[$offset], 'hours_ago' => 4 + $index, 'read_after_minutes' => $secondary ? 6 : null],
];
}
} }

View File

@ -10,6 +10,7 @@ use Modules\Category\Models\Category;
use Modules\Favorite\App\Models\FavoriteSearch; use Modules\Favorite\App\Models\FavoriteSearch;
use Modules\Listing\Models\Listing; use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User; use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class FavoriteDemoSeeder extends Seeder class FavoriteDemoSeeder extends Seeder
{ {
@ -19,47 +20,40 @@ class FavoriteDemoSeeder extends Seeder
return; return;
} }
$admin = User::query()->where('email', 'a@a.com')->first(); $users = User::query()
$member = User::query()->where('email', 'b@b.com')->first(); ->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
->get()
->values();
if (! $admin || ! $member) { if ($users->count() < 2) {
return; return;
} }
$adminListings = Listing::query() DB::table('favorite_listings')->whereIn('user_id', $users->pluck('id'))->delete();
->where('user_id', $admin->getKey()) DB::table('favorite_sellers')->whereIn('user_id', $users->pluck('id'))->delete();
->orderByDesc('is_featured') FavoriteSearch::query()->whereIn('user_id', $users->pluck('id'))->delete();
->orderBy('id')
->get();
$memberListings = Listing::query() foreach ($users as $index => $user) {
->where('user_id', $member->getKey()) $favoriteSeller = $users->get(($index + 1) % $users->count());
->orderByDesc('is_featured') $secondarySeller = $users->get(($index + 2) % $users->count());
->orderBy('id')
->get();
if ($adminListings->isEmpty() || $memberListings->isEmpty()) { if (! $favoriteSeller instanceof User || ! $secondarySeller instanceof User) {
return; continue;
} }
$activeAdminListings = $adminListings->where('status', 'active')->values(); $favoriteListings = Listing::query()
$activeMemberListings = $memberListings->where('status', 'active')->values(); ->whereIn('user_id', [$favoriteSeller->getKey(), $secondarySeller->getKey()])
->where('status', 'active')
->orderByDesc('is_featured')
->orderBy('id')
->take(4)
->get();
$this->seedFavoriteListings( $this->seedFavoriteListings($user, $favoriteListings);
$member, $this->seedFavoriteSeller($user, $favoriteSeller, now()->subDays($index + 1));
$activeAdminListings->take(6) $this->seedFavoriteSearches($user, $this->searchPayloadsForUser($index));
); }
$this->seedFavoriteListings(
$admin,
$activeMemberListings->take(4)
);
$this->seedFavoriteSeller($member, $admin, now()->subDays(2));
$this->seedFavoriteSeller($admin, $member, now()->subDays(1));
$this->seedFavoriteSearches($member, $this->memberSearchPayloads());
$this->seedFavoriteSearches($admin, $this->adminSearchPayloads());
} }
private function favoriteTablesExist(): bool private function favoriteTablesExist(): bool
@ -74,7 +68,7 @@ class FavoriteDemoSeeder extends Seeder
$rows = $listings $rows = $listings
->values() ->values()
->map(function (Listing $listing, int $index) use ($user): array { ->map(function (Listing $listing, int $index) use ($user): array {
$timestamp = now()->subHours(12 + ($index * 5)); $timestamp = now()->subHours(8 + ($index * 3));
return [ return [
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
@ -154,31 +148,28 @@ class FavoriteDemoSeeder extends Seeder
} }
} }
private function memberSearchPayloads(): array private function searchPayloadsForUser(int $index): array
{ {
$electronicsId = Category::query()->where('slug', 'electronics')->value('id'); $blueprints = [
$vehiclesId = Category::query()->where('slug', 'vehicles')->value('id'); ['search' => 'phone', 'slug' => 'electronics'],
$realEstateId = Category::query()->where('slug', 'real-estate')->value('id'); ['search' => 'car', 'slug' => 'vehicles'],
$servicesId = Category::query()->where('slug', 'services')->value('id'); ['search' => 'apartment', 'slug' => 'real-estate'],
['search' => 'style', 'slug' => 'fashion'],
['search' => 'furniture', 'slug' => 'home-garden'],
['search' => 'fitness', 'slug' => 'sports'],
['search' => 'remote', 'slug' => 'jobs'],
['search' => 'cleaning', 'slug' => 'services'],
];
return collect(range(0, 2))
->map(function (int $offset) use ($blueprints, $index): array {
$blueprint = $blueprints[($index + $offset) % count($blueprints)];
return [ return [
['search' => 'iphone', 'category_id' => $electronicsId], 'search' => $blueprint['search'],
['search' => 'sedan', 'category_id' => $vehiclesId], 'category_id' => Category::query()->where('slug', $blueprint['slug'])->value('id'),
['search' => 'apartment', 'category_id' => $realEstateId],
['search' => 'repair', 'category_id' => $servicesId],
];
}
private function adminSearchPayloads(): array
{
$fashionId = Category::query()->where('slug', 'fashion')->value('id');
$homeGardenId = Category::query()->where('slug', 'home-garden')->value('id');
$sportsId = Category::query()->where('slug', 'sports')->value('id');
return [
['search' => 'vintage', 'category_id' => $fashionId],
['search' => 'garden', 'category_id' => $homeGardenId],
['search' => 'fitness', 'category_id' => $sportsId],
]; ];
})
->all();
} }
} }

View File

@ -3,231 +3,79 @@
namespace Modules\Listing\Database\Seeders; namespace Modules\Listing\Database\Seeders;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing; use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User; use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class ListingPanelDemoSeeder extends Seeder class ListingPanelDemoSeeder extends Seeder
{ {
private const USER_PANEL_LISTINGS = [ private const LEGACY_SLUGS = [
'a@a.com' => [ 'admin-demo-pro-workstation',
[ 'admin-demo-sold-camera',
'slug' => 'admin-demo-pro-workstation', 'admin-demo-expired-sofa',
'title' => 'Admin Demo Pro Workstation', 'member-demo-iphone',
'description' => 'Active demo listing for inbox, favorites, and video testing.', 'member-demo-city-bike',
'price' => 28450, 'member-demo-vintage-chair',
'status' => 'active', 'member-demo-garden-tools',
'city' => 'Istanbul',
'country' => 'Turkey',
'image' => 'sample_image/macbook.jpg',
'expires_offset_days' => 21,
'is_featured' => true,
],
[
'slug' => 'admin-demo-sold-camera',
'title' => 'Admin Demo Camera Bundle',
'description' => 'Sold demo listing for panel status testing.',
'price' => 18450,
'status' => 'sold',
'city' => 'Ankara',
'country' => 'Turkey',
'image' => 'sample_image/headphones.jpg',
'expires_offset_days' => 12,
'is_featured' => false,
],
[
'slug' => 'admin-demo-expired-sofa',
'title' => 'Admin Demo Sofa Set',
'description' => 'Expired demo listing for republish testing.',
'price' => 9800,
'status' => 'expired',
'city' => 'Izmir',
'country' => 'Turkey',
'image' => 'sample_image/cup.jpg',
'expires_offset_days' => -5,
'is_featured' => false,
],
],
'b@b.com' => [
[
'slug' => 'member-demo-iphone',
'title' => 'Member Demo iPhone Bundle',
'description' => 'Active demo listing owned by the member account.',
'price' => 21990,
'status' => 'active',
'city' => 'Bursa',
'country' => 'Turkey',
'image' => 'sample_image/phone.jpeg',
'expires_offset_days' => 24,
'is_featured' => true,
],
[
'slug' => 'member-demo-city-bike',
'title' => 'Member Demo City Bike',
'description' => 'Second active listing so conversations and favorites are easy to test.',
'price' => 7600,
'status' => 'active',
'city' => 'Antalya',
'country' => 'Turkey',
'image' => 'sample_image/car2.jpeg',
'expires_offset_days' => 17,
'is_featured' => false,
],
[
'slug' => 'member-demo-vintage-chair',
'title' => 'Member Demo Vintage Chair',
'description' => 'Sold listing for panel filters on the member account.',
'price' => 4350,
'status' => 'sold',
'city' => 'Istanbul',
'country' => 'Turkey',
'image' => 'sample_image/car.jpeg',
'expires_offset_days' => 8,
'is_featured' => false,
],
[
'slug' => 'member-demo-garden-tools',
'title' => 'Member Demo Garden Tools Set',
'description' => 'Expired listing for republish flow on the member account.',
'price' => 3150,
'status' => 'expired',
'city' => 'Ankara',
'country' => 'Turkey',
'image' => 'sample_image/laptop.jpg',
'expires_offset_days' => -7,
'is_featured' => false,
],
],
]; ];
public function run(): void public function run(): void
{
$admin = $this->resolveUserByEmail('a@a.com');
if (! $admin) {
return;
}
$this->claimAllListingsForAdmin($admin);
$categories = $this->resolveCategories();
if ($categories->isEmpty()) {
return;
}
foreach (self::USER_PANEL_LISTINGS as $email => $payloads) {
$owner = $this->resolveUserByEmail($email);
if (! $owner) {
continue;
}
foreach ($payloads as $index => $payload) {
$category = $categories->get($index % $categories->count());
if (! $category instanceof Category) {
continue;
}
$listing = Listing::updateOrCreate(
['slug' => $payload['slug']],
[
'slug' => $payload['slug'],
'title' => $payload['title'],
'description' => $payload['description'],
'price' => $payload['price'],
'currency' => 'TRY',
'city' => $payload['city'],
'country' => $payload['country'],
'category_id' => $category->getKey(),
'user_id' => $owner->getKey(),
'status' => $payload['status'],
'contact_email' => $owner->email,
'contact_phone' => $email === 'a@a.com' ? '+905551112233' : '+905551112244',
'is_featured' => $payload['is_featured'],
'expires_at' => now()->addDays((int) $payload['expires_offset_days']),
]
);
$this->syncListingImage($listing, (string) $payload['image']);
}
}
}
private function resolveUserByEmail(string $email): ?User
{
return User::query()->where('email', $email)->first();
}
private function claimAllListingsForAdmin(User $admin): void
{ {
Listing::query() Listing::query()
->where(function ($query) use ($admin): void { ->whereIn('slug', self::LEGACY_SLUGS)
$query ->get()
->whereNull('user_id') ->each(function (Listing $listing): void {
->orWhere('user_id', '!=', $admin->getKey()); $listing->clearMediaCollection('listing-images');
}) $listing->delete();
->update([ });
'user_id' => $admin->getKey(),
'contact_email' => $admin->email, foreach (DemoUserCatalog::emails() as $email) {
'updated_at' => now(), $user = User::query()->where('email', $email)->first();
]);
if (! $user) {
continue;
} }
private function resolveCategories(): Collection $this->applyPanelStates($user);
}
}
private function applyPanelStates(User $user): void
{ {
$leafCategories = Category::query() $listings = Listing::query()
->where('is_active', true) ->where('user_id', $user->getKey())
->whereDoesntHave('children') ->where('slug', 'like', 'demo-%')
->orderBy('sort_order') ->orderBy('created_at')
->orderBy('name')
->get();
if ($leafCategories->isNotEmpty()) {
return $leafCategories->values();
}
return Category::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get() ->get()
->values(); ->values();
foreach ($listings as $index => $listing) {
$listing->forceFill([
'status' => $this->statusForIndex($index),
'is_featured' => $index < 2,
'expires_at' => $this->expiresAtForIndex($index),
'updated_at' => now()->subHours($index),
])->saveQuietly();
}
} }
private function syncListingImage(Listing $listing, string $imageRelativePath): void private function statusForIndex(int $index): string
{ {
$imageAbsolutePath = public_path($imageRelativePath); return match ($index % 9) {
2 => 'sold',
if (! is_file($imageAbsolutePath)) { 3 => 'expired',
return; 4 => 'pending',
default => 'active',
};
} }
$targetFileName = basename($imageAbsolutePath); private function expiresAtForIndex(int $index): \Illuminate\Support\Carbon
$existingMedia = $listing->getMedia('listing-images')->first(); {
return match ($this->statusForIndex($index)) {
if ( 'expired' => now()->subDays(4 + ($index % 5)),
$existingMedia 'sold' => now()->addDays(8 + ($index % 4)),
&& (string) $existingMedia->file_name === $targetFileName 'pending' => now()->addDays(5 + ($index % 4)),
&& (string) $existingMedia->disk === 'public' default => now()->addDays(20 + ($index % 9)),
) { };
try {
if (is_file($existingMedia->getPath())) {
return;
}
} catch (\Throwable) {
}
}
$listing->clearMediaCollection('listing-images');
$listing
->addMedia($imageAbsolutePath)
->usingFileName(Str::slug($listing->slug).'-'.basename($imageAbsolutePath))
->preservingOriginal()
->toMediaCollection('listing-images', 'public');
} }
} }

View File

@ -8,9 +8,11 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Modules\Category\Models\Category; use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing; use Modules\Listing\Models\Listing;
use Modules\Listing\Support\DemoListingImageFactory;
use Modules\Location\Models\City; use Modules\Location\Models\City;
use Modules\Location\Models\Country; use Modules\Location\Models\Country;
use Modules\User\App\Models\User; use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class ListingSeeder extends Seeder class ListingSeeder extends Seeder
{ {
@ -26,31 +28,45 @@ class ListingSeeder extends Seeder
public function run(): void public function run(): void
{ {
$user = $this->resolveSeederUser(); $users = $this->resolveSeederUsers();
$categories = $this->resolveSeedableCategories(); $categories = $this->resolveSeedableCategories();
if (! $user || $categories->isEmpty()) { if ($users->isEmpty() || $categories->isEmpty()) {
return; return;
} }
$countries = $this->resolveCountries(); $countries = $this->resolveCountries();
$turkeyCities = $this->resolveTurkeyCities(); $turkeyCities = $this->resolveTurkeyCities();
$plannedSlugs = [];
foreach ($categories as $index => $category) { foreach ($users as $userIndex => $user) {
$listingData = $this->buildListingData($category, $index, $countries, $turkeyCities); foreach ($categories as $categoryIndex => $category) {
$listing = $this->upsertListing($index, $listingData, $category, $user); $listingIndex = ($userIndex * max(1, $categories->count())) + $categoryIndex;
$this->syncListingImage($listing, $listingData['image']); $listingData = $this->buildListingData($category, $listingIndex, $countries, $turkeyCities, $user);
$listing = $this->upsertListing($listingData, $category, $user);
$plannedSlugs[] = $listing->slug;
$this->syncListingImage($listing, $listingData['image_path']);
} }
} }
private function resolveSeederUser(): ?User Listing::query()
->whereIn('user_id', $users->pluck('id'))
->where('slug', 'like', 'demo-%')
->whereNotIn('slug', $plannedSlugs)
->get()
->each(function (Listing $listing): void {
$listing->clearMediaCollection('listing-images');
$listing->delete();
});
}
private function resolveSeederUsers(): Collection
{ {
return User::query()->where('email', 'a@a.com')->first() return User::query()
?? User::query()->where('email', 'admin@openclassify.com')->first() ->whereIn('email', DemoUserCatalog::emails())
?? User::query() ->orderBy('email')
->whereHas('roles', fn ($query) => $query->where('name', 'admin')) ->get()
->first() ->values();
?? User::query()->first();
} }
private function resolveSeedableCategories(): Collection private function resolveSeedableCategories(): Collection
@ -58,6 +74,7 @@ class ListingSeeder extends Seeder
$leafCategories = Category::query() $leafCategories = Category::query()
->where('is_active', true) ->where('is_active', true)
->whereDoesntHave('children') ->whereDoesntHave('children')
->with('parent:id,name')
->orderBy('sort_order') ->orderBy('sort_order')
->orderBy('name') ->orderBy('name')
->get(); ->get();
@ -68,6 +85,7 @@ class ListingSeeder extends Seeder
return Category::query() return Category::query()
->where('is_active', true) ->where('is_active', true)
->with('parent:id,name')
->orderBy('sort_order') ->orderBy('sort_order')
->orderBy('name') ->orderBy('name')
->get() ->get()
@ -119,17 +137,32 @@ class ListingSeeder extends Seeder
Category $category, Category $category,
int $index, int $index,
Collection $countries, Collection $countries,
Collection $turkeyCities Collection $turkeyCities,
User $user
): array { ): array {
$location = $this->resolveLocation($index, $countries, $turkeyCities); $location = $this->resolveLocation($index, $countries, $turkeyCities);
$title = $this->buildTitle($category, $index, $user);
$slug = 'demo-'.Str::slug($user->email).'-'.$category->slug;
$familyName = trim((string) ($category->parent?->name ?? $category->name));
return [ return [
'title' => $this->buildTitle($category, $index), 'slug' => $slug,
'description' => $this->buildDescription($category, $location['city'], $location['country']), 'title' => $title,
'description' => $this->buildDescription($category, $location['city'], $location['country'], $user),
'price' => $this->priceForIndex($index), 'price' => $this->priceForIndex($index),
'city' => $location['city'], 'city' => $location['city'],
'country' => $location['country'], 'country' => $location['country'],
'image' => null, 'contact_phone' => DemoUserCatalog::phoneFor($user->email),
'is_featured' => $index % 7 === 0,
'expires_at' => now()->addDays(21 + ($index % 9)),
'created_at' => now()->subHours(6 + $index),
'image_path' => DemoListingImageFactory::ensure(
$slug,
$title,
$familyName,
$user->name,
$index
),
]; ];
} }
@ -137,7 +170,6 @@ class ListingSeeder extends Seeder
{ {
$turkeyCountry = $countries->first(fn ($country): bool => strtoupper((string) $country->code) === 'TR'); $turkeyCountry = $countries->first(fn ($country): bool => strtoupper((string) $country->code) === 'TR');
$turkeyName = trim((string) ($turkeyCountry->name ?? 'Turkey')) ?: 'Turkey'; $turkeyName = trim((string) ($turkeyCountry->name ?? 'Turkey')) ?: 'Turkey';
$useForeignCountry = $countries->count() > 1 && $index % 4 === 0; $useForeignCountry = $countries->count() > 1 && $index % 4 === 0;
if ($useForeignCountry) { if ($useForeignCountry) {
@ -164,22 +196,29 @@ class ListingSeeder extends Seeder
]; ];
} }
private function buildTitle(Category $category, int $index): string private function buildTitle(Category $category, int $index, User $user): string
{ {
$prefix = self::TITLE_PREFIXES[$index % count(self::TITLE_PREFIXES)]; $prefix = self::TITLE_PREFIXES[$index % count(self::TITLE_PREFIXES)];
$categoryName = trim((string) $category->name); $categoryName = trim((string) $category->name);
$ownerFragment = trim(Str::before($user->name, ' '));
return sprintf('%s %s listing', $prefix, $categoryName !== '' ? $categoryName : 'item'); return sprintf(
'%s %s for %s',
$prefix,
$categoryName !== '' ? $categoryName : 'item',
$ownerFragment !== '' ? $ownerFragment : 'demo'
);
} }
private function buildDescription(Category $category, string $city, string $country): string private function buildDescription(Category $category, string $city, string $country, User $user): string
{ {
$categoryName = trim((string) $category->name); $categoryName = trim((string) $category->name);
$location = trim(collect([$city, $country])->filter()->join(', ')); $location = trim(collect([$city, $country])->filter()->join(', '));
return sprintf( return sprintf(
'Listed in %s, in clean condition and ready to use. Pickup area: %s. Message for more details.', '%s listed by %s. Clean demo condition, unique seeded media, and ready for browsing, favorites, inbox, and panel testing. Pickup area: %s.',
$categoryName !== '' ? $categoryName : 'Item', $categoryName !== '' ? $categoryName : 'Item',
trim((string) $user->name) !== '' ? trim((string) $user->name) : 'a marketplace user',
$location !== '' ? $location : 'Turkey' $location !== '' ? $location : 'Turkey'
); );
} }
@ -203,14 +242,12 @@ class ListingSeeder extends Seeder
return $base + $step; return $base + $step;
} }
private function upsertListing(int $index, array $data, Category $category, User $user): Listing private function upsertListing(array $data, Category $category, User $user): Listing
{ {
$slug = Str::slug($category->slug.'-'.$data['title']).'-'.($index + 1); $listing = Listing::updateOrCreate(
['slug' => $data['slug']],
return Listing::updateOrCreate(
['slug' => $slug],
[ [
'slug' => $slug, 'slug' => $data['slug'],
'title' => $data['title'], 'title' => $data['title'],
'description' => $data['description'], 'description' => $data['description'],
'price' => $data['price'], 'price' => $data['price'],
@ -221,63 +258,22 @@ class ListingSeeder extends Seeder
'user_id' => $user->id, 'user_id' => $user->id,
'status' => 'active', 'status' => 'active',
'contact_email' => $user->email, 'contact_email' => $user->email,
'contact_phone' => '+905551112233', 'contact_phone' => $data['contact_phone'],
'is_featured' => $index < 8, 'is_featured' => $data['is_featured'],
'expires_at' => $data['expires_at'],
] ]
); );
$listing->forceFill([
'created_at' => $data['created_at'],
'updated_at' => $data['created_at'],
])->saveQuietly();
return $listing;
} }
private function syncListingImage(Listing $listing, ?string $imageRelativePath): void private function syncListingImage(Listing $listing, string $imageAbsolutePath): void
{ {
if (blank($imageRelativePath)) { $listing->replacePublicImage($imageAbsolutePath, $listing->slug.'.svg');
$listing->clearMediaCollection('listing-images');
return;
}
$imageAbsolutePath = public_path($imageRelativePath);
if (! is_file($imageAbsolutePath)) {
if ($this->command) {
$this->command->warn("Image not found: {$imageRelativePath}");
}
return;
}
$targetFileName = basename($imageAbsolutePath);
$mediaItems = $listing->getMedia('listing-images');
if (! $this->hasSingleHealthyTargetMedia($mediaItems, $targetFileName)) {
$listing->clearMediaCollection('listing-images');
$listing
->addMedia($imageAbsolutePath)
->preservingOriginal()
->toMediaCollection('listing-images', 'public');
}
}
private function hasSingleHealthyTargetMedia(Collection $mediaItems, string $targetFileName): bool
{
if ($mediaItems->count() !== 1) {
return false;
}
$media = $mediaItems->first();
if (
! $media
|| (string) $media->file_name !== $targetFileName
|| (string) $media->disk !== 'public'
) {
return false;
}
try {
return is_file($media->getPath());
} catch (\Throwable) {
return false;
}
} }
} }

View File

@ -18,6 +18,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\ModelStates\HasStates; use Spatie\ModelStates\HasStates;
use Throwable;
class Listing extends Model implements HasMedia class Listing extends Model implements HasMedia
{ {
@ -347,6 +348,41 @@ class Listing extends Model implements HasMedia
]; ];
} }
public function replacePublicImage(string $absolutePath, ?string $fileName = null): void
{
if (! is_file($absolutePath)) {
return;
}
$targetFileName = trim((string) ($fileName ?: basename($absolutePath)));
$existingMediaItems = $this->getMedia('listing-images');
if ($existingMediaItems->count() === 1) {
$existingMedia = $existingMediaItems->first();
if (
$existingMedia
&& (string) $existingMedia->file_name === $targetFileName
&& (string) $existingMedia->disk === 'public'
) {
try {
if (is_file($existingMedia->getPath())) {
return;
}
} catch (Throwable) {
}
}
}
$this->clearMediaCollection('listing-images');
$this
->addMedia($absolutePath)
->usingFileName($targetFileName)
->preservingOriginal()
->toMediaCollection('listing-images', 'public');
}
public function statusValue(): string public function statusValue(): string
{ {
return $this->status instanceof ListingStatus return $this->status instanceof ListingStatus

View File

@ -0,0 +1,84 @@
<?php
namespace Modules\Listing\Support;
use Illuminate\Support\Str;
final class DemoListingImageFactory
{
private const PALETTES = [
['#0f172a', '#1d4ed8', '#dbeafe'],
['#172554', '#2563eb', '#dbeafe'],
['#0f3b2e', '#059669', '#d1fae5'],
['#3f2200', '#ea580c', '#ffedd5'],
['#3b0764', '#9333ea', '#f3e8ff'],
['#3f3f46', '#e11d48', '#ffe4e6'],
['#0b3b66', '#0891b2', '#cffafe'],
['#422006', '#ca8a04', '#fef3c7'],
];
public static function ensure(
string $slug,
string $title,
string $categoryName,
string $ownerName,
int $seed
): string {
$directory = public_path('generated/demo-listings');
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
$filePath = $directory.'/'.Str::slug($slug).'.svg';
$palette = self::PALETTES[$seed % count(self::PALETTES)];
[$baseColor, $accentColor, $surfaceColor] = $palette;
$shortTitle = self::escape(Str::limit($title, 36, ''));
$shortCategory = self::escape(Str::limit($categoryName, 18, ''));
$shortOwner = self::escape(Str::limit($ownerName, 18, ''));
$code = self::escape(strtoupper(Str::substr(md5($slug), 0, 6)));
$svg = <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1200" viewBox="0 0 1600 1200" fill="none">
<defs>
<linearGradient id="bg" x1="120" y1="80" x2="1480" y2="1120" gradientUnits="userSpaceOnUse">
<stop stop-color="{$baseColor}"/>
<stop offset="1" stop-color="{$accentColor}"/>
</linearGradient>
<linearGradient id="glass" x1="320" y1="220" x2="1200" y2="920" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.95"/>
<stop offset="1" stop-color="{$surfaceColor}" stop-opacity="0.86"/>
</linearGradient>
</defs>
<rect width="1600" height="1200" rx="64" fill="url(#bg)"/>
<circle cx="1320" cy="230" r="170" fill="white" fill-opacity="0.08"/>
<circle cx="240" cy="1010" r="220" fill="white" fill-opacity="0.06"/>
<rect x="170" y="146" width="1260" height="908" rx="58" fill="url(#glass)" stroke="white" stroke-opacity="0.22" stroke-width="6"/>
<rect x="260" y="248" width="420" height="700" rx="44" fill="{$baseColor}" fill-opacity="0.94"/>
<rect x="740" y="248" width="520" height="200" rx="34" fill="white" fill-opacity="0.72"/>
<rect x="740" y="490" width="520" height="210" rx="34" fill="white" fill-opacity="0.52"/>
<rect x="740" y="742" width="240" height="180" rx="30" fill="white" fill-opacity="0.58"/>
<rect x="1020" y="742" width="240" height="180" rx="30" fill="white" fill-opacity="0.32"/>
<rect x="340" y="338" width="260" height="260" rx="130" fill="white" fill-opacity="0.12"/>
<rect x="830" y="310" width="230" height="38" rx="19" fill="{$accentColor}" fill-opacity="0.16"/>
<rect x="830" y="548" width="340" height="26" rx="13" fill="{$baseColor}" fill-opacity="0.12"/>
<rect x="830" y="596" width="260" height="26" rx="13" fill="{$baseColor}" fill-opacity="0.08"/>
<text x="262" y="214" fill="white" fill-opacity="0.92" font-family="Arial, Helvetica, sans-serif" font-size="40" font-weight="700" letter-spacing="10">OPENCLASSIFY DEMO</text>
<text x="258" y="760" fill="white" font-family="Arial, Helvetica, sans-serif" font-size="86" font-weight="700">{$shortCategory}</text>
<text x="258" y="840" fill="white" fill-opacity="0.78" font-family="Arial, Helvetica, sans-serif" font-size="44" font-weight="500">{$shortOwner}</text>
<text x="818" y="390" fill="{$baseColor}" font-family="Arial, Helvetica, sans-serif" font-size="72" font-weight="700">{$shortTitle}</text>
<text x="818" y="474" fill="{$accentColor}" font-family="Arial, Helvetica, sans-serif" font-size="34" font-weight="700" letter-spacing="8">{$code}</text>
</svg>
SVG;
file_put_contents($filePath, $svg);
return $filePath;
}
private static function escape(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace Modules\User\App\Support;
final class DemoUserCatalog
{
public static function records(): array
{
return [
[
'email' => 'a@a.com',
'name' => 'Admin',
'password' => '236330',
'phone' => '+905551112233',
'is_admin' => true,
],
[
'email' => 'b@b.com',
'name' => 'Member',
'password' => '236330',
'phone' => '+905551112244',
'is_admin' => false,
],
[
'email' => 'c@c.com',
'name' => 'Ava Carter',
'password' => '236330',
'phone' => '+905551112255',
'is_admin' => false,
],
[
'email' => 'd@d.com',
'name' => 'Liam Stone',
'password' => '236330',
'phone' => '+905551112266',
'is_admin' => false,
],
[
'email' => 'e@e.com',
'name' => 'Mila Reed',
'password' => '236330',
'phone' => '+905551112277',
'is_admin' => false,
],
];
}
public static function emails(): array
{
return array_column(self::records(), 'email');
}
public static function phoneFor(string $email): string
{
foreach (self::records() as $record) {
if ($record['email'] === $email) {
return $record['phone'];
}
}
return '+905551110000';
}
public static function isAdmin(string $email): bool
{
foreach (self::records() as $record) {
if ($record['email'] === $email) {
return (bool) $record['is_admin'];
}
}
return false;
}
}

View File

@ -5,29 +5,22 @@ namespace Modules\User\Database\Seeders;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Modules\User\App\Models\User; use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
use Spatie\Permission\Models\Role; use Spatie\Permission\Models\Role;
class AuthUserSeeder extends Seeder class AuthUserSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
$admin = User::query()->updateOrCreate( $users = collect(DemoUserCatalog::records())
['email' => 'a@a.com'], ->map(fn (array $record): User => User::query()->updateOrCreate(
['email' => $record['email']],
[ [
'name' => 'Admin', 'name' => $record['name'],
'password' => '236330', 'password' => $record['password'],
'status' => 'active', 'status' => 'active',
], ],
); ));
User::query()->updateOrCreate(
['email' => 'b@b.com'],
[
'name' => 'Member',
'password' => '236330',
'status' => 'active',
],
);
if (! class_exists(Role::class) || ! Schema::hasTable((new Role())->getTable())) { if (! class_exists(Role::class) || ! Schema::hasTable((new Role())->getTable())) {
return; return;
@ -38,6 +31,14 @@ class AuthUserSeeder extends Seeder
'guard_name' => 'web', 'guard_name' => 'web',
]); ]);
$admin->syncRoles([$adminRole->name]); $users->each(function (User $user) use ($adminRole): void {
if (DemoUserCatalog::isAdmin($user->email)) {
$user->syncRoles([$adminRole->name]);
return;
}
$user->syncRoles([]);
});
} }
} }

View File

@ -6,72 +6,34 @@ use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
use Modules\Listing\Models\Listing; use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User; use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
use Modules\Video\Enums\VideoStatus; use Modules\Video\Enums\VideoStatus;
use Modules\Video\Models\Video; use Modules\Video\Models\Video;
class VideoDemoSeeder extends Seeder class VideoDemoSeeder extends Seeder
{ {
private const VIDEO_BLUEPRINTS = [
'a@a.com' => [
[
'title' => 'Workspace walkaround',
'description' => 'Pending demo video for upload and processing states.',
'status' => VideoStatus::Pending,
'is_active' => true,
'processing_error' => null,
],
[
'title' => 'Packaging close-up',
'description' => 'Failed demo video to test retry and edit flows.',
'status' => VideoStatus::Failed,
'is_active' => false,
'processing_error' => 'Demo processing was skipped intentionally.',
],
],
'b@b.com' => [
[
'title' => 'Short product overview',
'description' => 'Pending demo video for the member workspace.',
'status' => VideoStatus::Pending,
'is_active' => true,
'processing_error' => null,
],
[
'title' => 'Condition details clip',
'description' => 'Failed demo video to show a second panel state.',
'status' => VideoStatus::Failed,
'is_active' => false,
'processing_error' => 'Demo processing was skipped intentionally.',
],
],
];
public function run(): void public function run(): void
{ {
if (! Schema::hasTable('videos') || ! Schema::hasTable('listings')) { if (! Schema::hasTable('videos') || ! Schema::hasTable('listings')) {
return; return;
} }
foreach (self::VIDEO_BLUEPRINTS as $email => $blueprints) { $users = User::query()
$user = User::query()->where('email', $email)->first(); ->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
if (! $user) { ->get()
continue; ->values();
}
foreach ($users as $userIndex => $user) {
$listings = Listing::query() $listings = Listing::query()
->where('user_id', $user->getKey()) ->where('user_id', $user->getKey())
->where('status', 'active') ->where('status', 'active')
->orderBy('id') ->orderBy('id')
->take(count($blueprints)) ->take(2)
->get(); ->get();
foreach ($blueprints as $index => $blueprint) { foreach ($listings as $listingIndex => $listing) {
$listing = $listings->get($index); $blueprint = $this->blueprintFor($userIndex, $listingIndex);
if (! $listing) {
continue;
}
$video = Video::query()->firstOrNew([ $video = Video::query()->firstOrNew([
'listing_id' => $listing->getKey(), 'listing_id' => $listing->getKey(),
@ -91,7 +53,7 @@ class VideoDemoSeeder extends Seeder
'upload_path' => null, 'upload_path' => null,
'mime_type' => 'video/mp4', 'mime_type' => 'video/mp4',
'size' => null, 'size' => null,
'sort_order' => $index + 1, 'sort_order' => $listingIndex + 1,
'is_active' => $blueprint['is_active'], 'is_active' => $blueprint['is_active'],
'processing_error' => $blueprint['processing_error'], 'processing_error' => $blueprint['processing_error'],
'processed_at' => null, 'processed_at' => null,
@ -99,4 +61,25 @@ class VideoDemoSeeder extends Seeder
} }
} }
} }
private function blueprintFor(int $userIndex, int $listingIndex): array
{
if ($listingIndex === 0) {
return [
'title' => 'Quick walkthrough '.($userIndex + 1),
'description' => 'Pending demo video for uploader and panel testing.',
'status' => VideoStatus::Pending,
'is_active' => true,
'processing_error' => null,
];
}
return [
'title' => 'Condition details '.($userIndex + 1),
'description' => 'Failed demo video for status handling and retry UI testing.',
'status' => VideoStatus::Failed,
'is_active' => false,
'processing_error' => 'Demo processing was skipped intentionally.',
];
}
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB