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;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Models\ConversationMessage;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class ConversationDemoSeeder extends Seeder
{
@ -18,72 +18,59 @@ class ConversationDemoSeeder extends Seeder
return;
}
$admin = User::query()->where('email', 'a@a.com')->first();
$member = User::query()->where('email', 'b@b.com')->first();
$users = User::query()
->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
->get()
->values();
if (! $admin || ! $member) {
if ($users->count() < 2) {
return;
}
$adminListings = Listing::query()
->where('user_id', $admin->getKey())
->where('status', 'active')
->orderBy('id')
->take(2)
->get();
ConversationMessage::query()
->whereHas('conversation', fn ($query) => $query->whereIn('buyer_id', $users->pluck('id'))->orWhereIn('seller_id', $users->pluck('id')))
->delete();
$memberListings = Listing::query()
->where('user_id', $member->getKey())
->where('status', 'active')
->orderBy('id')
->take(2)
->get();
Conversation::query()
->whereIn('buyer_id', $users->pluck('id'))
->orWhereIn('seller_id', $users->pluck('id'))
->delete();
if ($adminListings->count() < 2 || $memberListings->count() < 2) {
return;
foreach ($users as $index => $buyer) {
$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(
$primarySeller,
$buyer,
$primaryListing,
$this->messagePayloads($index, false)
);
$this->seedConversationThread(
$secondarySeller,
$buyer,
$secondaryListing,
$this->messagePayloads($index, true)
);
}
$this->seedConversationThread(
$admin,
$member,
$adminListings->get(0),
[
['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(
$admin,
$member,
$adminListings->get(1),
[
['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
@ -97,7 +84,7 @@ class ConversationDemoSeeder extends Seeder
?Listing $listing,
array $messages
): void {
if (! $listing) {
if (! $listing || (int) $seller->getKey() === (int) $buyer->getKey()) {
return;
}
@ -112,10 +99,6 @@ class ConversationDemoSeeder extends Seeder
]
);
ConversationMessage::query()
->where('conversation_id', $conversation->getKey())
->delete();
$lastMessageAt = null;
foreach ($messages as $payload) {
@ -137,10 +120,47 @@ class ConversationDemoSeeder extends Seeder
$lastMessageAt = $createdAt;
}
$conversation->forceFill([
'seller_id' => $seller->getKey(),
'last_message_at' => $lastMessageAt,
'updated_at' => $lastMessageAt,
])->saveQuietly();
if ($lastMessageAt) {
$conversation->forceFill([
'seller_id' => $seller->getKey(),
'last_message_at' => $lastMessageAt,
'updated_at' => $lastMessageAt,
])->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\Listing\Models\Listing;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class FavoriteDemoSeeder extends Seeder
{
@ -19,47 +20,40 @@ class FavoriteDemoSeeder extends Seeder
return;
}
$admin = User::query()->where('email', 'a@a.com')->first();
$member = User::query()->where('email', 'b@b.com')->first();
$users = User::query()
->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
->get()
->values();
if (! $admin || ! $member) {
if ($users->count() < 2) {
return;
}
$adminListings = Listing::query()
->where('user_id', $admin->getKey())
->orderByDesc('is_featured')
->orderBy('id')
->get();
DB::table('favorite_listings')->whereIn('user_id', $users->pluck('id'))->delete();
DB::table('favorite_sellers')->whereIn('user_id', $users->pluck('id'))->delete();
FavoriteSearch::query()->whereIn('user_id', $users->pluck('id'))->delete();
$memberListings = Listing::query()
->where('user_id', $member->getKey())
->orderByDesc('is_featured')
->orderBy('id')
->get();
foreach ($users as $index => $user) {
$favoriteSeller = $users->get(($index + 1) % $users->count());
$secondarySeller = $users->get(($index + 2) % $users->count());
if ($adminListings->isEmpty() || $memberListings->isEmpty()) {
return;
if (! $favoriteSeller instanceof User || ! $secondarySeller instanceof User) {
continue;
}
$favoriteListings = Listing::query()
->whereIn('user_id', [$favoriteSeller->getKey(), $secondarySeller->getKey()])
->where('status', 'active')
->orderByDesc('is_featured')
->orderBy('id')
->take(4)
->get();
$this->seedFavoriteListings($user, $favoriteListings);
$this->seedFavoriteSeller($user, $favoriteSeller, now()->subDays($index + 1));
$this->seedFavoriteSearches($user, $this->searchPayloadsForUser($index));
}
$activeAdminListings = $adminListings->where('status', 'active')->values();
$activeMemberListings = $memberListings->where('status', 'active')->values();
$this->seedFavoriteListings(
$member,
$activeAdminListings->take(6)
);
$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
@ -74,7 +68,7 @@ class FavoriteDemoSeeder extends Seeder
$rows = $listings
->values()
->map(function (Listing $listing, int $index) use ($user): array {
$timestamp = now()->subHours(12 + ($index * 5));
$timestamp = now()->subHours(8 + ($index * 3));
return [
'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');
$vehiclesId = Category::query()->where('slug', 'vehicles')->value('id');
$realEstateId = Category::query()->where('slug', 'real-estate')->value('id');
$servicesId = Category::query()->where('slug', 'services')->value('id');
return [
['search' => 'iphone', 'category_id' => $electronicsId],
['search' => 'sedan', 'category_id' => $vehiclesId],
['search' => 'apartment', 'category_id' => $realEstateId],
['search' => 'repair', 'category_id' => $servicesId],
$blueprints = [
['search' => 'phone', 'slug' => 'electronics'],
['search' => 'car', 'slug' => 'vehicles'],
['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'],
];
}
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 collect(range(0, 2))
->map(function (int $offset) use ($blueprints, $index): array {
$blueprint = $blueprints[($index + $offset) % count($blueprints)];
return [
['search' => 'vintage', 'category_id' => $fashionId],
['search' => 'garden', 'category_id' => $homeGardenId],
['search' => 'fitness', 'category_id' => $sportsId],
];
return [
'search' => $blueprint['search'],
'category_id' => Category::query()->where('slug', $blueprint['slug'])->value('id'),
];
})
->all();
}
}

View File

@ -3,231 +3,79 @@
namespace Modules\Listing\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class ListingPanelDemoSeeder extends Seeder
{
private const USER_PANEL_LISTINGS = [
'a@a.com' => [
[
'slug' => 'admin-demo-pro-workstation',
'title' => 'Admin Demo Pro Workstation',
'description' => 'Active demo listing for inbox, favorites, and video testing.',
'price' => 28450,
'status' => 'active',
'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,
],
],
private const LEGACY_SLUGS = [
'admin-demo-pro-workstation',
'admin-demo-sold-camera',
'admin-demo-expired-sofa',
'member-demo-iphone',
'member-demo-city-bike',
'member-demo-vintage-chair',
'member-demo-garden-tools',
];
public function run(): void
{
$admin = $this->resolveUserByEmail('a@a.com');
Listing::query()
->whereIn('slug', self::LEGACY_SLUGS)
->get()
->each(function (Listing $listing): void {
$listing->clearMediaCollection('listing-images');
$listing->delete();
});
if (! $admin) {
return;
}
foreach (DemoUserCatalog::emails() as $email) {
$user = User::query()->where('email', $email)->first();
$this->claimAllListingsForAdmin($admin);
$categories = $this->resolveCategories();
if ($categories->isEmpty()) {
return;
}
foreach (self::USER_PANEL_LISTINGS as $email => $payloads) {
$owner = $this->resolveUserByEmail($email);
if (! $owner) {
if (! $user) {
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']);
}
$this->applyPanelStates($user);
}
}
private function resolveUserByEmail(string $email): ?User
private function applyPanelStates(User $user): void
{
return User::query()->where('email', $email)->first();
}
private function claimAllListingsForAdmin(User $admin): void
{
Listing::query()
->where(function ($query) use ($admin): void {
$query
->whereNull('user_id')
->orWhere('user_id', '!=', $admin->getKey());
})
->update([
'user_id' => $admin->getKey(),
'contact_email' => $admin->email,
'updated_at' => now(),
]);
}
private function resolveCategories(): Collection
{
$leafCategories = Category::query()
->where('is_active', true)
->whereDoesntHave('children')
->orderBy('sort_order')
->orderBy('name')
->get();
if ($leafCategories->isNotEmpty()) {
return $leafCategories->values();
}
return Category::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
$listings = Listing::query()
->where('user_id', $user->getKey())
->where('slug', 'like', 'demo-%')
->orderBy('created_at')
->get()
->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',
3 => 'expired',
4 => 'pending',
default => 'active',
};
}
if (! is_file($imageAbsolutePath)) {
return;
}
$targetFileName = basename($imageAbsolutePath);
$existingMedia = $listing->getMedia('listing-images')->first();
if (
$existingMedia
&& (string) $existingMedia->file_name === $targetFileName
&& (string) $existingMedia->disk === 'public'
) {
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');
private function expiresAtForIndex(int $index): \Illuminate\Support\Carbon
{
return match ($this->statusForIndex($index)) {
'expired' => now()->subDays(4 + ($index % 5)),
'sold' => now()->addDays(8 + ($index % 4)),
'pending' => now()->addDays(5 + ($index % 4)),
default => now()->addDays(20 + ($index % 9)),
};
}
}

View File

@ -8,9 +8,11 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\DemoListingImageFactory;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class ListingSeeder extends Seeder
{
@ -26,31 +28,45 @@ class ListingSeeder extends Seeder
public function run(): void
{
$user = $this->resolveSeederUser();
$users = $this->resolveSeederUsers();
$categories = $this->resolveSeedableCategories();
if (! $user || $categories->isEmpty()) {
if ($users->isEmpty() || $categories->isEmpty()) {
return;
}
$countries = $this->resolveCountries();
$turkeyCities = $this->resolveTurkeyCities();
$plannedSlugs = [];
foreach ($categories as $index => $category) {
$listingData = $this->buildListingData($category, $index, $countries, $turkeyCities);
$listing = $this->upsertListing($index, $listingData, $category, $user);
$this->syncListingImage($listing, $listingData['image']);
foreach ($users as $userIndex => $user) {
foreach ($categories as $categoryIndex => $category) {
$listingIndex = ($userIndex * max(1, $categories->count())) + $categoryIndex;
$listingData = $this->buildListingData($category, $listingIndex, $countries, $turkeyCities, $user);
$listing = $this->upsertListing($listingData, $category, $user);
$plannedSlugs[] = $listing->slug;
$this->syncListingImage($listing, $listingData['image_path']);
}
}
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 resolveSeederUser(): ?User
private function resolveSeederUsers(): Collection
{
return User::query()->where('email', 'a@a.com')->first()
?? User::query()->where('email', 'admin@openclassify.com')->first()
?? User::query()
->whereHas('roles', fn ($query) => $query->where('name', 'admin'))
->first()
?? User::query()->first();
return User::query()
->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
->get()
->values();
}
private function resolveSeedableCategories(): Collection
@ -58,6 +74,7 @@ class ListingSeeder extends Seeder
$leafCategories = Category::query()
->where('is_active', true)
->whereDoesntHave('children')
->with('parent:id,name')
->orderBy('sort_order')
->orderBy('name')
->get();
@ -68,6 +85,7 @@ class ListingSeeder extends Seeder
return Category::query()
->where('is_active', true)
->with('parent:id,name')
->orderBy('sort_order')
->orderBy('name')
->get()
@ -119,17 +137,32 @@ class ListingSeeder extends Seeder
Category $category,
int $index,
Collection $countries,
Collection $turkeyCities
Collection $turkeyCities,
User $user
): array {
$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 [
'title' => $this->buildTitle($category, $index),
'description' => $this->buildDescription($category, $location['city'], $location['country']),
'slug' => $slug,
'title' => $title,
'description' => $this->buildDescription($category, $location['city'], $location['country'], $user),
'price' => $this->priceForIndex($index),
'city' => $location['city'],
'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');
$turkeyName = trim((string) ($turkeyCountry->name ?? 'Turkey')) ?: 'Turkey';
$useForeignCountry = $countries->count() > 1 && $index % 4 === 0;
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)];
$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);
$location = trim(collect([$city, $country])->filter()->join(', '));
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',
trim((string) $user->name) !== '' ? trim((string) $user->name) : 'a marketplace user',
$location !== '' ? $location : 'Turkey'
);
}
@ -203,14 +242,12 @@ class ListingSeeder extends Seeder
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);
return Listing::updateOrCreate(
['slug' => $slug],
$listing = Listing::updateOrCreate(
['slug' => $data['slug']],
[
'slug' => $slug,
'slug' => $data['slug'],
'title' => $data['title'],
'description' => $data['description'],
'price' => $data['price'],
@ -221,63 +258,22 @@ class ListingSeeder extends Seeder
'user_id' => $user->id,
'status' => 'active',
'contact_email' => $user->email,
'contact_phone' => '+905551112233',
'is_featured' => $index < 8,
'contact_phone' => $data['contact_phone'],
'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->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;
}
$listing->replacePublicImage($imageAbsolutePath, $listing->slug.'.svg');
}
}

View File

@ -18,6 +18,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\ModelStates\HasStates;
use Throwable;
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
{
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\Support\Facades\Schema;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
use Spatie\Permission\Models\Role;
class AuthUserSeeder extends Seeder
{
public function run(): void
{
$admin = User::query()->updateOrCreate(
['email' => 'a@a.com'],
[
'name' => 'Admin',
'password' => '236330',
'status' => 'active',
],
);
User::query()->updateOrCreate(
['email' => 'b@b.com'],
[
'name' => 'Member',
'password' => '236330',
'status' => 'active',
],
);
$users = collect(DemoUserCatalog::records())
->map(fn (array $record): User => User::query()->updateOrCreate(
['email' => $record['email']],
[
'name' => $record['name'],
'password' => $record['password'],
'status' => 'active',
],
));
if (! class_exists(Role::class) || ! Schema::hasTable((new Role())->getTable())) {
return;
@ -38,6 +31,14 @@ class AuthUserSeeder extends Seeder
'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 Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
use Modules\Video\Enums\VideoStatus;
use Modules\Video\Models\Video;
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
{
if (! Schema::hasTable('videos') || ! Schema::hasTable('listings')) {
return;
}
foreach (self::VIDEO_BLUEPRINTS as $email => $blueprints) {
$user = User::query()->where('email', $email)->first();
if (! $user) {
continue;
}
$users = User::query()
->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
->get()
->values();
foreach ($users as $userIndex => $user) {
$listings = Listing::query()
->where('user_id', $user->getKey())
->where('status', 'active')
->orderBy('id')
->take(count($blueprints))
->take(2)
->get();
foreach ($blueprints as $index => $blueprint) {
$listing = $listings->get($index);
if (! $listing) {
continue;
}
foreach ($listings as $listingIndex => $listing) {
$blueprint = $this->blueprintFor($userIndex, $listingIndex);
$video = Video::query()->firstOrNew([
'listing_id' => $listing->getKey(),
@ -91,7 +53,7 @@ class VideoDemoSeeder extends Seeder
'upload_path' => null,
'mime_type' => 'video/mp4',
'size' => null,
'sort_order' => $index + 1,
'sort_order' => $listingIndex + 1,
'is_active' => $blueprint['is_active'],
'processing_error' => $blueprint['processing_error'],
'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