diff --git a/app/Console/Commands/SyncMakerWorldCollectionsCommand.php b/app/Console/Commands/SyncMakerWorldCollectionsCommand.php new file mode 100644 index 0000000..996a145 --- /dev/null +++ b/app/Console/Commands/SyncMakerWorldCollectionsCommand.php @@ -0,0 +1,77 @@ +info('MakerWorld Collecties Synchronisatie gestart...'); + + $query = MakerWorldCollection::query(); + + if ($specificId = $this->option('collection')) { + $query->where(function ($q) use ($specificId) { + $q->where('id', $specificId) + ->orWhere('makerworld_collection_id', $specificId); + }); + } elseif (! $this->option('all')) { + $query->where('is_active_sync', true); + } + + $collections = $query->get(); + + if ($collections->isEmpty()) { + $this->comment('Geen actieve MakerWorld collecties gevonden om te synchroniseren.'); + + return self::SUCCESS; + } + + $this->line("Aantal collecties te synchroniseren: {$collections->count()}"); + + $totalImported = 0; + $totalUpdated = 0; + + foreach ($collections as $collection) { + $this->info("Synchroniseren van '{$collection->name}' (#{$collection->makerworld_collection_id})..."); + + $result = $makerWorld->syncCollection($collection); + + if ($result['error']) { + $this->error("Fout bij '{$collection->name}': {$result['error']}"); + } else { + $this->line(" ✓ {$result['imported']} nieuwe modellen binnengehaald als concept."); + $this->line(" ✓ {$result['updated']} bestaande modellen bijgewerkt."); + $totalImported += $result['imported']; + $totalUpdated += $result['updated']; + } + } + + $this->info("Klaar! Totaal {$totalImported} nieuwe modellen binnengehaald en {$totalUpdated} bijgewerkt."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 96cca92..427430c 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -68,4 +68,3 @@ class AccountController extends Controller return back()->with('success', 'Jouw wachtwoord is succesvol gewijzigd.'); } } - diff --git a/app/Http/Controllers/Admin/MakerWorldController.php b/app/Http/Controllers/Admin/MakerWorldController.php index 533e915..b376612 100644 --- a/app/Http/Controllers/Admin/MakerWorldController.php +++ b/app/Http/Controllers/Admin/MakerWorldController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\Category; +use App\Models\MakerWorldCollection; use App\Models\Product; use App\Services\MakerWorldService; use Illuminate\Http\JsonResponse; @@ -18,17 +19,22 @@ class MakerWorldController extends Controller ) {} /** - * Display the MakerWorld importer dashboard. + * Display the MakerWorld importer & collections dashboard. */ public function index(Request $request): View { $isAuthenticated = $this->makerWorld->isAuthenticated(); $userProfile = $this->makerWorld->getUserProfile(); - $tab = $request->query('tab', $isAuthenticated ? 'favorites' : 'community'); + $tab = $request->query('tab', 'collections'); $models = []; + $apiCollections = []; - if ($tab === 'favorites' && $isAuthenticated) { + if ($tab === 'collections') { + if ($isAuthenticated) { + $apiCollections = $this->makerWorld->getFavoritesCollections(); + } + } elseif ($tab === 'favorites' && $isAuthenticated) { $models = $this->makerWorld->getFavorites(); } elseif ($tab === 'published' && $isAuthenticated) { $models = $this->makerWorld->getPublished(); @@ -36,15 +42,11 @@ class MakerWorldController extends Controller $models = $this->makerWorld->getCommunityShowcase(); } - $importedProducts = Product::with('category') - ->whereNotNull('makerworld_id') + $trackedCollections = MakerWorldCollection::withCount(['products', 'draftProducts', 'publishedProducts']) ->latest() - ->paginate(15); - - $importedIds = Product::whereNotNull('makerworld_id') - ->pluck('makerworld_id') - ->toArray(); + ->get(); + $stagedCount = Product::where('status', 'draft')->count(); $categories = Category::orderBy('name')->get(); return view('admin.makerworld.index', [ @@ -52,12 +54,266 @@ class MakerWorldController extends Controller 'userProfile' => $userProfile, 'currentTab' => $tab, 'models' => $models, - 'importedProducts' => $importedProducts, - 'importedIds' => $importedIds, + 'apiCollections' => $apiCollections, + 'trackedCollections' => $trackedCollections, + 'stagedCount' => $stagedCount, 'categories' => $categories, ]); } + /** + * Display the "Artikel Beheer" staging area where imported models wait for review. + */ + public function staging(Request $request): View + { + $query = Product::with(['category', 'makerworldCollection']) + ->where('status', 'draft'); + + if ($request->filled('collection_id')) { + $query->where('makerworld_collection_id', $request->input('collection_id')); + } + + if ($request->filled('q')) { + $q = $request->input('q'); + $query->where(function ($sub) use ($q) { + $sub->where('name', 'like', "%{$q}%") + ->orWhere('makerworld_author', 'like', "%{$q}%") + ->orWhere('makerworld_id', 'like', "%{$q}%"); + }); + } + + $stagedProducts = $query->latest()->paginate(24)->withQueryString(); + $categories = Category::orderBy('name')->get(); + $collections = MakerWorldCollection::all(); + $totalStaged = Product::where('status', 'draft')->count(); + + return view('admin.makerworld.staging', compact('stagedProducts', 'categories', 'collections', 'totalStaged')); + } + + /** + * Add a MakerWorld collection to be tracked and auto-synced. + */ + public function addCollection(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'collection_input' => 'required|string', + 'is_active_sync' => 'nullable|boolean', + 'sync_now' => 'nullable|boolean', + ]); + + $input = trim($validated['collection_input']); + $parsed = $this->makerWorld->parseCollectionIdentifier($input); + + $collectionId = $parsed['id']; + $userHandle = $parsed['handle'] ?? 'user_1718037659'; + + // If user entered a user collections URL or handle without specific collection ID + if ($parsed['type'] === 'user_collections' && ! $collectionId) { + // Attempt to fetch collection list for user + $userCollections = $this->makerWorld->getFavoritesCollections(); + + if (! empty($userCollections)) { + $addedCount = 0; + foreach ($userCollections as $c) { + $cId = (string) ($c['id'] ?? ''); + if ($cId !== '') { + $collection = MakerWorldCollection::firstOrCreate( + ['makerworld_collection_id' => $cId], + [ + 'name' => $c['title'] ?? 'MakerWorld Collectie #'.$cId, + 'description' => $c['description'] ?? null, + 'cover_url' => is_array($c['designCover'] ?? null) ? ($c['designCover'][0] ?? null) : ($c['cover'] ?? null), + 'user_handle' => $userHandle, + 'source_url' => $input, + 'is_active_sync' => true, + 'sync_interval_minutes' => 10, + ] + ); + $this->makerWorld->syncCollection($collection); + $addedCount++; + } + } + + return redirect()->route('admin.makerworld.index', ['tab' => 'collections']) + ->with('success', "{$addedCount} collecties van @{$userHandle} succesvol gekoppeld en gesynchroniseerd!"); + } + + return redirect()->route('admin.makerworld.index', ['tab' => 'collections']) + ->with('warning', "Gebruikersprofiel @{$userHandle} herkend. Voer a.u.b. een specifieke collectie-link of ID in (bijv. https://makerworld.com/nl/collections/123456) of log in bij MakerWorld."); + } + + if (! $collectionId) { + return redirect()->route('admin.makerworld.index', ['tab' => 'collections']) + ->with('error', 'Kon geen geldige MakerWorld collectie ID of URL herkennen.'); + } + + // Fetch collection details from MakerWorld + $details = $this->makerWorld->getCollectionDetails($collectionId); + $name = $details['title'] ?? 'MakerWorld Collectie #'.$collectionId; + $cover = is_array($details['designCover'] ?? null) ? ($details['designCover'][0] ?? null) : ($details['cover'] ?? null); + $desc = $details['description'] ?? null; + + $collection = MakerWorldCollection::updateOrCreate( + ['makerworld_collection_id' => (string) $collectionId], + [ + 'name' => $name, + 'description' => $desc, + 'cover_url' => $cover, + 'user_handle' => $userHandle, + 'source_url' => $input, + 'is_active_sync' => $request->boolean('is_active_sync', true), + 'sync_interval_minutes' => 10, + 'raw_metadata' => $details, + ] + ); + + $msg = "Collectie '{$collection->name}' is succesvol toegevoegd!"; + + if ($request->boolean('sync_now', true)) { + $syncResult = $this->makerWorld->syncCollection($collection); + $msg .= " Direct {$syncResult['imported']} modellen binnengehaald als concept voor Artikel Beheer."; + } + + return redirect()->route('admin.makerworld.index', ['tab' => 'collections']) + ->with('success', $msg); + } + + /** + * Toggle active 10-minute sync for a collection. + */ + public function toggleCollectionSync(MakerWorldCollection $collection): RedirectResponse + { + $collection->update([ + 'is_active_sync' => ! $collection->is_active_sync, + ]); + + $statusStr = $collection->is_active_sync ? 'geactiveerd (elke 10 min)' : 'gepauzeerd'; + + return redirect()->back() + ->with('success', "Automatische synchronisatie voor '{$collection->name}' is {$statusStr}."); + } + + /** + * Trigger immediate synchronization for a collection. + */ + public function syncCollectionNow(MakerWorldCollection $collection): RedirectResponse + { + $result = $this->makerWorld->syncCollection($collection); + + if ($result['error']) { + return redirect()->back() + ->with('error', "Synchronisatie mislukt: {$result['error']}"); + } + + return redirect()->back() + ->with('success', "Collectie '{$collection->name}' gesynchroniseerd: {$result['imported']} nieuwe modellen binnengehaald, {$result['updated']} bijgewerkt."); + } + + /** + * Remove a tracked collection. + */ + public function deleteCollection(MakerWorldCollection $collection): RedirectResponse + { + $name = $collection->name; + $collection->delete(); + + return redirect()->route('admin.makerworld.index', ['tab' => 'collections']) + ->with('success', "Collectie '{$name}' is verwijderd uit de synchronisatie."); + } + + /** + * Publish a single staged model to the active webshop catalog. + */ + public function publishStaged(Request $request, Product $product): RedirectResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'category_id' => 'required|exists:categories,id', + 'base_price' => 'required|numeric|min:0.5', + 'short_description' => 'nullable|string|max:500', + 'is_featured' => 'nullable|boolean', + ]); + + $product->update([ + 'name' => $validated['name'], + 'category_id' => $validated['category_id'], + 'base_price' => $validated['base_price'], + 'short_description' => $validated['short_description'] ?? $product->short_description, + 'is_featured' => $request->boolean('is_featured', false), + 'status' => 'published', + 'is_active' => true, + ]); + + return redirect()->back() + ->with('success', "Artikel '{$product->name}' is succesvol goedgekeurd en gepubliceerd in de webshop!"); + } + + /** + * Bulk publish selected staged models. + */ + public function bulkPublishStaged(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'product_ids' => 'required|array', + 'product_ids.*' => 'integer|exists:products,id', + 'category_id' => 'required|exists:categories,id', + 'custom_markup' => 'nullable|numeric', + ]); + + $products = Product::whereIn('id', $validated['product_ids']) + ->where('status', 'draft') + ->get(); + + $count = 0; + foreach ($products as $p) { + $price = $p->base_price; + if (! empty($validated['custom_markup']) && $validated['custom_markup'] > 0) { + $price = round($price * (1 + ($validated['custom_markup'] / 100)), 2); + } + + $p->update([ + 'category_id' => $validated['category_id'], + 'base_price' => $price, + 'status' => 'published', + 'is_active' => true, + ]); + $count++; + } + + return redirect()->back() + ->with('success', "{$count} artikelen succesvol gepubliceerd in de webshop!"); + } + + /** + * Reject and delete a staged model. + */ + public function rejectStaged(Product $product): RedirectResponse + { + $name = $product->name; + $product->delete(); + + return redirect()->back() + ->with('success', "Concept '{$name}' is verwijderd."); + } + + /** + * Bulk reject staged models. + */ + public function bulkRejectStaged(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'product_ids' => 'required|array', + 'product_ids.*' => 'integer|exists:products,id', + ]); + + $count = Product::whereIn('id', $validated['product_ids']) + ->where('status', 'draft') + ->delete(); + + return redirect()->back() + ->with('success', "{$count} concepten zijn verwijderd."); + } + /** * Authenticate with MakerWorld credentials. */ @@ -75,7 +331,7 @@ class MakerWorldController extends Controller if ($result['status'] === 'success') { return redirect() - ->route('admin.makerworld.index', ['tab' => 'favorites']) + ->route('admin.makerworld.index', ['tab' => 'collections']) ->with('success', $result['message']); } @@ -108,7 +364,7 @@ class MakerWorldController extends Controller if ($result['status'] === 'success') { return redirect() - ->route('admin.makerworld.index', ['tab' => 'favorites']) + ->route('admin.makerworld.index', ['tab' => 'collections']) ->with('success', $result['message']); } @@ -130,7 +386,7 @@ class MakerWorldController extends Controller $this->makerWorld->setAccessToken($validated['token']); return redirect() - ->route('admin.makerworld.index', ['tab' => 'favorites']) + ->route('admin.makerworld.index', ['tab' => 'collections']) ->with('success', 'MakerWorld token succesvol opgeslagen en verbonden!'); } @@ -142,7 +398,7 @@ class MakerWorldController extends Controller $this->makerWorld->logout(); return redirect() - ->route('admin.makerworld.index', ['tab' => 'community']) + ->route('admin.makerworld.index', ['tab' => 'collections']) ->with('success', 'MakerWorld account succesvol ontkoppeld.'); } @@ -191,6 +447,7 @@ class MakerWorldController extends Controller 'custom_price' => 'nullable|numeric|min:0.5', 'custom_name' => 'nullable|string|max:255', 'is_active' => 'nullable|boolean', + 'status' => 'nullable|string|in:draft,published', ]); $details = $this->makerWorld->getDesignDetails($validated['makerworld_id']); @@ -204,15 +461,18 @@ class MakerWorldController extends Controller return redirect()->back()->with('error', $msg); } + $status = $validated['status'] ?? ($request->boolean('is_active', true) ? 'published' : 'draft'); + $product = $this->makerWorld->importDesign( $details, $validated['category_id'] ?? null, $validated['custom_price'] ?? null, $validated['custom_name'] ?? null, - $request->boolean('is_active', true) + $status === 'published', + $status ); - $msg = "Model \"{$product->name}\" is succesvol toegevoegd aan je webshop!"; + $msg = "Model \"{$product->name}\" is succesvol toegevoegd!"; if ($request->wantsJson()) { return response()->json([ @@ -249,12 +509,13 @@ class MakerWorldController extends Controller $categoryId, null, null, - $isActive + $isActive, + $isActive ? 'published' : 'draft' ); $count++; } } - return redirect()->back()->with('success', "{$count} modellen succesvol geïmporteerd naar je webshop!"); + return redirect()->back()->with('success', "{$count} modellen succesvol geïmporteerd!"); } } diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php index 5c16587..29824ee 100644 --- a/app/Http/Controllers/Auth/ForgotPasswordController.php +++ b/app/Http/Controllers/Auth/ForgotPasswordController.php @@ -122,4 +122,3 @@ class ForgotPasswordController extends Controller ->with('success', 'Jouw wachtwoord is succesvol gewijzigd! Je bent direct ingelogd.'); } } - diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 1e7e943..fcae0cf 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -70,4 +70,3 @@ class LoginController extends Controller ->with('success', 'Je bent succesvol uitgelogd.'); } } - diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 881beff..459de51 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -65,4 +65,3 @@ class RegisterController extends Controller ->with('success', "Welkom bij Jouwdriedee.nl, {$user->name}! Je account is aangemaakt en er is een bevestiging gestuurd naar {$user->email}."); } } - diff --git a/app/Http/Controllers/ShopController.php b/app/Http/Controllers/ShopController.php index 53cca67..98b1352 100644 --- a/app/Http/Controllers/ShopController.php +++ b/app/Http/Controllers/ShopController.php @@ -19,7 +19,7 @@ class ShopController extends Controller ->get(); $featuredProducts = Product::with('category') - ->where('is_active', true) + ->published() ->where('is_featured', true) ->latest() ->take(6) @@ -32,7 +32,7 @@ class ShopController extends Controller public function catalog(Request $request) { - $query = Product::with('category')->where('is_active', true); + $query = Product::with('category')->published(); // Filter op categorie if ($request->filled('category')) { @@ -75,13 +75,13 @@ class ShopController extends Controller public function show($slug) { - $product = Product::with('category')->where('slug', $slug)->firstOrFail(); + $product = Product::with('category')->published()->where('slug', $slug)->firstOrFail(); $allMaterials = Material::where('is_active', true)->get()->keyBy('code'); $allColors = Color::where('in_stock', true)->get(); - $relatedProducts = Product::where('id', '!=', $product->id) + $relatedProducts = Product::published() + ->where('id', '!=', $product->id) ->where('category_id', $product->category_id) - ->where('is_active', true) ->take(3) ->get(); diff --git a/app/Http/Middleware/EnsureUserIsAdmin.php b/app/Http/Middleware/EnsureUserIsAdmin.php index 7ca6492..888b1f2 100644 --- a/app/Http/Middleware/EnsureUserIsAdmin.php +++ b/app/Http/Middleware/EnsureUserIsAdmin.php @@ -26,4 +26,3 @@ class EnsureUserIsAdmin return $next($request); } } - diff --git a/app/Models/MakerWorldCollection.php b/app/Models/MakerWorldCollection.php new file mode 100644 index 0000000..43d981e --- /dev/null +++ b/app/Models/MakerWorldCollection.php @@ -0,0 +1,56 @@ + 'boolean', + 'sync_interval_minutes' => 'integer', + 'items_count' => 'integer', + 'last_synced_at' => 'datetime', + 'raw_metadata' => 'array', + ]; + + public function products(): HasMany + { + return $this->hasMany(Product::class, 'makerworld_collection_id'); + } + + public function draftProducts(): HasMany + { + return $this->hasMany(Product::class, 'makerworld_collection_id') + ->where('status', 'draft'); + } + + public function publishedProducts(): HasMany + { + return $this->hasMany(Product::class, 'makerworld_collection_id') + ->where('status', 'published'); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php index af178b1..c96897a 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -2,6 +2,7 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -13,6 +14,7 @@ class Product extends Model protected $fillable = [ 'makerworld_id', + 'makerworld_collection_id', 'category_id', 'name', 'slug', @@ -24,9 +26,11 @@ class Product extends Model 'stock', 'is_featured', 'is_active', + 'status', 'dimensions', 'weight_grams', 'print_time_hours', + 'print_info', 'image_url', 'makerworld_url', 'makerworld_author', @@ -43,6 +47,7 @@ class Product extends Model 'is_featured' => 'boolean', 'is_active' => 'boolean', 'print_time_hours' => 'float', + 'print_info' => 'array', 'gallery_images' => 'array', 'available_materials' => 'array', 'available_colors' => 'array', @@ -53,11 +58,30 @@ class Product extends Model return $this->belongsTo(Category::class); } + public function makerworldCollection(): BelongsTo + { + return $this->belongsTo(MakerWorldCollection::class, 'makerworld_collection_id'); + } + public function orderItems(): HasMany { return $this->hasMany(OrderItem::class); } + public function scopePublished(Builder $query): Builder + { + return $query->where('is_active', true) + ->where(function ($q) { + $q->where('status', 'published') + ->orWhereNull('status'); + }); + } + + public function scopeDraft(Builder $query): Builder + { + return $query->where('status', 'draft'); + } + public function getFormattedPriceAttribute(): string { return '€ '.number_format((float) ($this->base_price ?? 0), 2, ',', '.'); diff --git a/app/Services/MakerWorldService.php b/app/Services/MakerWorldService.php index 6f29487..c9337dd 100644 --- a/app/Services/MakerWorldService.php +++ b/app/Services/MakerWorldService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\MakerWorldCollection; use App\Models\Product; use App\Models\Setting; use Illuminate\Http\Client\PendingRequest; @@ -404,6 +405,160 @@ class MakerWorldService return null; } + /** + * Parse a MakerWorld collection URL, handle, or ID. + * Examples: + * - https://makerworld.com/nl/@user_1718037659/collections -> type: user_collections, handle: user_1718037659 + * - https://makerworld.com/nl/collections/9205051 -> type: collection, id: 9205051 + * - 9205051 -> type: collection, id: 9205051 + * + * @return array{type: string, id: ?int, handle: ?string, raw: string} + */ + public function parseCollectionIdentifier(string $input): array + { + $trimmed = trim($input); + + // Direct numeric ID + if (is_numeric($trimmed)) { + return [ + 'type' => 'collection', + 'id' => (int) $trimmed, + 'handle' => null, + 'raw' => $trimmed, + ]; + } + + // Collection URL: /collections/12345 + if (preg_match('/collections\/(\d+)/i', $trimmed, $matches)) { + return [ + 'type' => 'collection', + 'id' => (int) $matches[1], + 'handle' => null, + 'raw' => $trimmed, + ]; + } + + // User collections URL: /@user_1718037659/collections + if (preg_match('/@([a-zA-Z0-9_\-]+)\/collections/i', $trimmed, $matches)) { + return [ + 'type' => 'user_collections', + 'id' => null, + 'handle' => $matches[1], + 'raw' => $trimmed, + ]; + } + + // Just handle: @user_1718037659 + if (str_starts_with($trimmed, '@')) { + return [ + 'type' => 'user_collections', + 'id' => null, + 'handle' => ltrim($trimmed, '@'), + 'raw' => $trimmed, + ]; + } + + return [ + 'type' => 'unknown', + 'id' => null, + 'handle' => null, + 'raw' => $trimmed, + ]; + } + + /** + * Fetch user's favorite collections from MakerWorld. + * + * @return array> + */ + public function getFavoritesCollections(): array + { + $collections = []; + + // If authenticated, get logged-in user's collections + if ($this->isAuthenticated()) { + try { + $response = $this->client()->get("{$this->baseUrl}/v1/design-service/my/favorites"); + if ($response->successful()) { + $data = $response->json(); + $list = $this->extractModelsList($data); + if (! empty($list)) { + return $list; + } + } + } catch (\Throwable $e) { + Log::warning('MakerWorld getFavoritesCollections (auth) failed: '.$e->getMessage()); + } + } + + // Public/featured collections tab fallback + try { + $response = $this->client()->get("{$this->baseUrl}/v1/design-service/favorites-collections/tab"); + if ($response->successful()) { + $data = $response->json(); + if (! empty($data['hits']) && is_array($data['hits'])) { + return $data['hits']; + } + } + } catch (\Throwable $e) { + Log::warning('MakerWorld getFavoritesCollections fallback failed: '.$e->getMessage()); + } + + return $collections; + } + + /** + * Fetch details of a specific collection by collection ID. + */ + public function getCollectionDetails(int|string $collectionId): ?array + { + try { + $response = $this->client()->get("{$this->baseUrl}/v1/design-service/favorites/{$collectionId}"); + + if ($response->successful()) { + $data = $response->json(); + if (! empty($data['id']) || ! empty($data['title'])) { + return $data; + } + } + } catch (\Throwable $e) { + Log::error("MakerWorld getCollectionDetails({$collectionId}) error: ".$e->getMessage()); + } + + return null; + } + + /** + * Fetch all design models inside a MakerWorld collection. + * + * @return array> + */ + public function getCollectionDesigns(int|string $collectionId, int $page = 1, int $limit = 100): array + { + $details = $this->getCollectionDetails($collectionId); + + if ($details && ! empty($details['designs']) && is_array($details['designs'])) { + return $details['designs']; + } + + try { + $response = $this->client()->get("{$this->baseUrl}/v1/design-service/favorites/designs/{$collectionId}", [ + 'page' => $page, + 'limit' => $limit, + ]); + + if ($response->successful()) { + $data = $response->json(); + + return $this->extractModelsList($data); + } + } catch (\Throwable $e) { + Log::error("MakerWorld getCollectionDesigns({$collectionId}) error: ".$e->getMessage()); + } + + return []; + } + /** * Transform raw MakerWorld API design details into a clean format for product creation. */ @@ -423,7 +578,7 @@ class MakerWorldService if (! empty($design['designExtension']['design_pictures']) && is_array($design['designExtension']['design_pictures'])) { foreach ($design['designExtension']['design_pictures'] as $pic) { - if (! empty($pic['url']) && $pic['url'] !== $coverUrl) { + if (! empty($pic['url']) && $pic['url'] !== $coverUrl && ! in_array($pic['url'], $gallery, true)) { $gallery[] = $pic['url']; } } @@ -437,10 +592,12 @@ class MakerWorldService } } - // Extract print profile stats (first/default instance) + // Extract print profile stats & instance information $weightGrams = null; $printTimeHours = null; $materials = ['pla', 'petg']; + $printInfo = []; + $dimensions = null; if (! empty($design['instances']) && is_array($design['instances'])) { $profile = $design['instances'][0]; @@ -453,6 +610,33 @@ class MakerWorldService $seconds = (int) $profile['prediction']; $printTimeHours = round($seconds / 3600, 1); } + + // Extract instance photos if available + if (! empty($profile['pictures']) && is_array($profile['pictures'])) { + foreach ($profile['pictures'] as $p) { + $url = is_array($p) ? ($p['url'] ?? null) : $p; + if ($url && $url !== $coverUrl && ! in_array($url, $gallery, true)) { + $gallery[] = $url; + } + } + } + + // Structured print info + $printInfo = [ + 'profile_title' => $profile['title'] ?? null, + 'printer' => $profile['printerName'] ?? $profile['modelName'] ?? 'Bambu Lab Printer', + 'nozzle' => $profile['nozzleDiameter'] ?? '0.4 mm', + 'weight_grams' => $weightGrams, + 'print_time_hours' => $printTimeHours, + 'prediction_seconds' => $profile['prediction'] ?? null, + 'plate_count' => count($profile['plates'] ?? []) ?: 1, + 'filaments' => $profile['filamentConfig'] ?? $profile['filaments'] ?? [], + ]; + } + + // Dimensions if specified + if (! empty($design['dimensions'])) { + $dimensions = is_array($design['dimensions']) ? implode(' x ', $design['dimensions']) : (string) $design['dimensions']; } // Calculate a smart suggested retail price @@ -486,13 +670,16 @@ class MakerWorldService 'stock' => 15, 'is_featured' => false, 'is_active' => true, + 'status' => 'published', + 'dimensions' => $dimensions, 'weight_grams' => $weightGrams, 'print_time_hours' => $printTimeHours, + 'print_info' => $printInfo, 'image_url' => $coverUrl, 'makerworld_url' => $makerworldUrl, 'makerworld_author' => $authorName, 'makerworld_license' => $license, - 'gallery_images' => array_slice($gallery, 0, 8), + 'gallery_images' => $gallery, 'available_materials' => $materials, 'available_colors' => ['Mat Zwart', 'Sneeuwwit', 'Leisteen Grijs', 'Koningsblauw'], ]; @@ -506,7 +693,9 @@ class MakerWorldService ?int $categoryId = null, ?float $customPrice = null, ?string $customName = null, - bool $isActive = true + bool $isActive = true, + string $status = 'published', + ?int $collectionId = null ): Product { $formatted = $this->formatDesignForImport($designData); @@ -524,6 +713,11 @@ class MakerWorldService } $formatted['is_active'] = $isActive; + $formatted['status'] = $status; + + if ($collectionId !== null) { + $formatted['makerworld_collection_id'] = $collectionId; + } // Ensure slug is unique $baseSlug = $formatted['slug']; @@ -539,6 +733,99 @@ class MakerWorldService ); } + /** + * Synchronize designs from a MakerWorld collection. + * New items are staged in 'draft' status (is_active = false) for admin curation. + * + * @return array{imported: int, updated: int, total: int, error: ?string} + */ + public function syncCollection(MakerWorldCollection $collection): array + { + $collection->update([ + 'last_sync_status' => 'running', + 'last_sync_message' => 'Synchronisatie bezig...', + ]); + + try { + $designs = $this->getCollectionDesigns($collection->makerworld_collection_id); + + $imported = 0; + $updated = 0; + + foreach ($designs as $design) { + $designId = (int) ($design['id'] ?? 0); + if ($designId <= 0) { + continue; + } + + $existingProduct = Product::where('makerworld_id', $designId)->first(); + + if ($existingProduct) { + // Associate with collection if not yet linked + $updates = []; + if (! $existingProduct->makerworld_collection_id) { + $updates['makerworld_collection_id'] = $collection->id; + } + if (empty($existingProduct->gallery_images) && ! empty($design['designExtension']['design_pictures'])) { + $formatted = $this->formatDesignForImport($design); + $updates['gallery_images'] = $formatted['gallery_images']; + } + if (empty($existingProduct->print_info)) { + $formatted = $this->formatDesignForImport($design); + $updates['print_info'] = $formatted['print_info']; + } + if (! empty($updates)) { + $existingProduct->update($updates); + } + $updated++; + } else { + // Fetch full details if necessary to get all high-res photos and instances + $fullDetails = $this->getDesignDetails($designId) ?? $design; + + $this->importDesign( + designData: $fullDetails, + categoryId: null, + customPrice: null, + customName: null, + isActive: false, + status: 'draft', + collectionId: $collection->id + ); + $imported++; + } + } + + $collection->update([ + 'items_count' => count($designs), + 'last_synced_at' => now(), + 'last_sync_status' => 'success', + 'last_sync_message' => "{$imported} nieuwe modellen geïmporteerd als concept, {$updated} bijgewerkt.", + ]); + + return [ + 'imported' => $imported, + 'updated' => $updated, + 'total' => count($designs), + 'error' => null, + ]; + } catch (\Throwable $e) { + Log::error("Sync collection {$collection->id} ({$collection->makerworld_collection_id}) error: ".$e->getMessage()); + + $collection->update([ + 'last_synced_at' => now(), + 'last_sync_status' => 'error', + 'last_sync_message' => 'Fout bij synchroniseren: '.$e->getMessage(), + ]); + + return [ + 'imported' => 0, + 'updated' => 0, + 'total' => 0, + 'error' => $e->getMessage(), + ]; + } + } + /** * Basic sanitization of MakerWorld HTML descriptions. */ diff --git a/database/migrations/2026_11_01_143224_create_makerworld_collections_table.php b/database/migrations/2026_11_01_143224_create_makerworld_collections_table.php new file mode 100644 index 0000000..206bc97 --- /dev/null +++ b/database/migrations/2026_11_01_143224_create_makerworld_collections_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('makerworld_collection_id')->unique()->index(); + $table->string('name'); + $table->string('slug')->nullable(); + $table->text('description')->nullable(); + $table->string('cover_url', 500)->nullable(); + $table->string('user_handle')->nullable()->index(); + $table->string('source_url', 500)->nullable(); + $table->boolean('is_active_sync')->default(false)->index(); + $table->unsignedInteger('sync_interval_minutes')->default(10); + $table->unsignedInteger('items_count')->default(0); + $table->timestamp('last_synced_at')->nullable(); + $table->string('last_sync_status')->nullable(); + $table->text('last_sync_message')->nullable(); + $table->json('raw_metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('makerworld_collections'); + } +}; diff --git a/database/migrations/2026_11_01_143227_add_status_and_collection_to_products_table.php b/database/migrations/2026_11_01_143227_add_status_and_collection_to_products_table.php new file mode 100644 index 0000000..8433dc4 --- /dev/null +++ b/database/migrations/2026_11_01_143227_add_status_and_collection_to_products_table.php @@ -0,0 +1,46 @@ +foreignId('makerworld_collection_id') + ->nullable() + ->after('makerworld_id') + ->constrained('makerworld_collections') + ->nullOnDelete(); + + $table->string('status') + ->default('published') + ->index() + ->after('is_active'); + + $table->json('print_info') + ->nullable() + ->after('print_time_hours'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropForeign(['makerworld_collection_id']); + $table->dropColumn([ + 'makerworld_collection_id', + 'status', + 'print_info', + ]); + }); + } +}; diff --git a/resources/views/admin/makerworld/index.blade.php b/resources/views/admin/makerworld/index.blade.php index a3d4fbf..5baaa7f 100644 --- a/resources/views/admin/makerworld/index.blade.php +++ b/resources/views/admin/makerworld/index.blade.php @@ -1,736 +1,573 @@ @extends('layouts.admin') -@section('title', 'MakerWorld Modellen Importeren | Jouwdriedee.nl Beheer') +@section('title', 'MakerWorld Collecties & Auto-Sync | Jouwdriedee.nl Beheer') @section('content') -
-
+
+
- +
-
- - Backend Beheer +
+ + MakerWorld Integratie • - 3D Model Synchronisatie + Collecties & 10-Minuten Auto-Sync
-

- - +

+ + - MakerWorld Product Importer + MakerWorld Product Importer & Collecties Sync

- Haal eenvoudig 3D modellen binnen vanaf makerworld.com en publiceer ze direct in jouw webshop. + Koppel je MakerWorld collecties (zoals @user_1718037659). Het systeem controleert elke 10 minuten automatisch op nieuwe modellen!

- -
- @if($isAuthenticated) -
- -
-
-
MakerWorld Status
-
- Verbonden - @if(!empty($userProfile['name']) || !empty($userProfile['account'])) - ({{ $userProfile['name'] ?? $userProfile['account'] }}) - @endif -
-
-
- @csrf - -
- @else -
- -
-
-
MakerWorld Status
-
Niet ingelogd
-
- - @endif + +
@if(session('success')) -
+
-
{{ session('success') }}
+
{{ session('success') }}
@endif @if(session('warning')) -
+
-
{{ session('warning') }}
+
{{ session('warning') }}
@endif @if(session('error')) -
+
-
{{ session('error') }}
+
{{ session('error') }}
@endif - -
-
-

- - Snel importeren via URL of Model-ID -

-

- Kopieer een link van een willekeurig model op MakerWorld (bijv. https://makerworld.com/nl/models/55866) of vul het ID in. -

+ + + + + @if($currentTab === 'collections') +
+ + +
+
+
+ + Nieuwe Collectie Toevoegen +
+

MakerWorld Collectie Koppelen aan Auto-Sync

+

+ Voer de link in van een collectie (bijv. https://makerworld.com/nl/collections/9205051) of je gebruikersprofiel https://makerworld.com/nl/@user_1718037659/collections. +

+ + + @csrf + +
+ +
+
+ +
+ +
+
+ +
+
+ + + +
+ + +
+ +
+
+ + +
+
+
+

+ + Actief Gevolgde Collecties (Auto-Sync) +

+

+ Modellen uit deze collecties worden binnengehaald als concepten in Artikel Beheer. +

+
+
+ + @if($trackedCollections->isEmpty()) +
+
+ +
+

Nog geen collecties gekoppeld

+

+ Voeg hierboven een collectie toe of gebruik je gebruikersnaam @user_1718037659 om automatische 10-minuten import te starten. +

+
+ @else +
+ @foreach($trackedCollections as $collection) +
+
+ +
+ @if($collection->cover_url) + {{ $collection->name }} + @else +
+ +
+ @endif +
+ + +
+ @if($collection->is_active_sync) + + + Auto-Sync 10m + + @else + + Gepauzeerd + + @endif +
+ +
+
+ ID: #{{ $collection->makerworld_collection_id }} • {{ $collection->user_handle ?? 'MakerWorld' }} +
+

+ {{ $collection->name }} +

+
+
+ + +
+ @if($collection->description) +

+ {{ $collection->description }} +

+ @endif + +
+
+
Totaal
+
{{ $collection->items_count }}
+
+
+
Concept
+
{{ $collection->draft_products_count }}
+
+
+
Live
+
{{ $collection->published_products_count }}
+
+
+ +
+
+ Laatste controle: + + {{ $collection->last_synced_at ? $collection->last_synced_at->diffForHumans() : 'Nog niet' }} + +
+ @if($collection->last_sync_message) +
+ Status: {{ $collection->last_sync_message }} +
+ @endif +
+
+
+ + +
+
+ +
+ @csrf + +
+ + +
+ @csrf + +
+
+ + +
+ @csrf + @method('DELETE') + +
+
+
+ @endforeach +
+ @endif +
+ +
+ @endif + + + @if(in_array($currentTab, ['community', 'favorites'])) +
+
+

+ + MakerWorld Modellen Showcase +

+

+ Selecteer modellen om direct toe te voegen aan je webshop catalogus. +

+
+ +
+ @forelse($models as $model) + @php + $mId = $model['id'] ?? 0; + $cover = $model['cover'] ?? $model['coverUrl'] ?? 'https://images.unsplash.com/photo-1581092160607-ee22621dd758?auto=format&fit=crop&w=600&q=80'; + $title = $model['title'] ?? 'MakerWorld Model #' . $mId; + $author = $model['designCreator']['name'] ?? $model['designCreator']['handle'] ?? 'MakerWorld Maker'; + @endphp +
+
+ {{ $title }} +
+
+
+ Door {{ $author }} + #{{ $mId }} +
+

+ {{ $title }} +

+
+ @csrf + + +
+
+
+ @empty +
+ Geen modellen gevonden in deze weergave. +
+ @endforelse +
+
+ @endif + + + @if($currentTab === 'quick') +
+
+

+ + Los Model Importeren via MakerWorld URL of ID +

+

+ Plak de URL van een specifiek model op MakerWorld (bijv. https://makerworld.com/nl/models/55866) om het direct op te halen met alle foto's en printgegevens. +

+
+ +
-
- +
+
-
-
- - - - - @if($currentTab !== 'imported') -
- - @if(empty($models)) -
-
- + +
+
+
+ +
+ +
-

Geen modellen gevonden in dit overzicht

-

- @if(!$isAuthenticated && in_array($currentTab, ['favorites', 'published'])) - Log eerst in met je MakerWorld account om je opgeslagen favorieten of ontwerpen te bekijken. - @else - Probeer de trending modellen te bekijken of plak direct een URL in het zoekveld hierboven. - @endif -

- @if(!$isAuthenticated) - - @endif -
- @else - -
-
- Toont {{ count($models) }} modellen van MakerWorld. Vink modellen aan om ze in één klik te importeren. -
-
- -
-
- -
- @foreach($models as $model) - @if(!is_array($model)) @continue @endif - @php - $mId = $model['id'] ?? 0; - $isAlreadyImported = in_array($mId, $importedIds); - $cover = $model['cover'] ?? $model['coverUrl'] ?? 'https://images.unsplash.com/photo-1581092160607-ee22621dd758?auto=format&fit=crop&w=600&q=80'; - $title = $model['title'] ?? 'MakerWorld Model #' . $mId; - $author = $model['designCreator']['name'] ?? $model['designCreator']['handle'] ?? 'MakerWorld Maker'; - @endphp -
- - -
- {{ $title }} - - -
- -
+
+
+
+

+

+
- -
- @if($isAlreadyImported) - - - In Webshop - - @endif - @if(!empty($model['printCount'])) - - - {{ number_format($model['printCount']) }} prints - - @endif -
- - - - +
+
+ Printtijd +
- - -
-
- Door {{ $author }} - #{{ $mId }} -
-

- {{ $title }} -

- - -
- -
+
+ Gewicht + +
+
+ Adviesprijs +
- @endforeach -
- @endif -
- - @else -
-
-
-

Geïmporteerde MakerWorld Producten in jouw Shop

-

Deze producten zijn actief of opgeslagen in jouw webshop database.

+ +
+ @csrf + + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
- - Bekijk publieke catalogus - - +
+
+ @endif + + + @if($currentTab === 'auth') +
+ +
+
+
+ +
+
+

Inloggen met MakerWorld

+

Bambu Lab / MakerWorld inloggegevens

+
+
+ +
+ @csrf +
+ + +
+ +
+ + +
+ + +
- @if($importedProducts->isEmpty()) -
-
- + +
+
+
+
-

Nog geen MakerWorld modellen geïmporteerd

-

- Selecteer hierboven modellen via de tabs of plak direct een MakerWorld link om je eerste product toe te voegen. -

-
- @else -
-
- - - - - - - - - - - - - - @foreach($importedProducts as $product) - - - - - - - - - - @endforeach - -
ProductMakerWorld RefCategorieVerkoopprijsPrint SpecsStatusActies
- {{ $product->name }} -
- - {{ $product->name }} - - SKU: {{ $product->sku ?? 'MW-' . $product->makerworld_id }} -
-
- - #{{ $product->makerworld_id }} - - -
{{ $product->makerworld_author ?? 'MakerWorld' }}
-
- - {{ $product->category->name ?? 'Ongecategoriseerd' }} - - - {{ $product->formatted_price }} - - @if($product->print_time_hours) -
⏱️ {{ $product->print_time_hours }} uur printtijd
- @endif - @if($product->weight_grams) -
⚖️ {{ $product->weight_grams }} gram filament
- @endif -
- @if($product->is_active) - - Actief in shop - - @else - - Concept / Verborgen - - @endif - - - - - -
+
+

MakerWorld Bearer Token

+

Plak direct je MakerWorld JWT token

- @if($importedProducts->hasPages()) -
- {{ $importedProducts->links() }} -
- @endif
- @endif + +
+ @csrf +
+ + +
+ + +
+ + @if($isAuthenticated) +
+ + + Momenteel actief gekoppeld + +
+ @csrf + +
+
+ @endif +
@endif
- - -
-
-
-
- -
-
-
modellen geselecteerd
-
Klaar om direct te publiceren in je webshop
-
-
- -
- @csrf - - - - - - - -
-
-
- - -
-
- - -
-
- -
-
-

Inloggen bij MakerWorld

-

Koppel jouw MakerWorld / Bambu Lab account

-
-
- - -
- - -
- - -
- @if(session('makerworld_2fa_account')) - -
- @csrf - -
- Voer de 6-cijferige verificatiecode in die Bambu Lab zojuist naar {{ session('makerworld_2fa_account') }} heeft gemaild. -
-
- - -
- -
- @else - -
- @csrf -
- - -
-
- - -
-

- 💡 Heeft je Bambu Lab account 2-stapsverificatie? Dan ontvang je automatisch een code per e-mail die je in de volgende stap invult. -

- -
- @endif -
- - -
-
- @csrf -
- - -
-
- Hoe vind je dit token?
- Als je al bent ingelogd op makerworld.com in je browser, druk op F12 → tabblad Toepassing/Application → Cookies → kopieer de waarde van het token cookie. -
- -
-
-
-
- - -
-
- - - -
- - - - -
Model gegevens ophalen van MakerWorld...
-
- - -
-
- @csrf - - - -
- -
-
- - • - -
-

-

- - -
- - - - - - - - - - - - -
-
-
- - -
-
- - -
- -
-
- - -
- -
- - -
-
- - -
-
-
Direct actief in de webshop
-
Bezoekers kunnen dit product direct bekijken en bestellen
-
- -
-
- - -
- - -
-
-
-
-
-
@endsection - diff --git a/resources/views/admin/makerworld/staging.blade.php b/resources/views/admin/makerworld/staging.blade.php new file mode 100644 index 0000000..d09ed33 --- /dev/null +++ b/resources/views/admin/makerworld/staging.blade.php @@ -0,0 +1,355 @@ +@extends('layouts.admin') + +@section('title', 'Artikel Beheer (Geïmporteerde Modellen) | Jouwdriedee.nl Beheer') + +@section('content') +
+
+ + +
+
+
+ + Staging & Curation + • + Geïmporteerde Modellen +
+

+ + + + Artikel Beheer +

+

+ Beoordeel automatisch binnengehaalde MakerWorld modellen. Stel de gewenste categorie en verkoopprijs in en publiceer ze met 1 klik naar de webshop! +

+
+ + + +
+ + + @if(session('success')) +
+ +
{{ session('success') }}
+
+ @endif + + @if(session('error')) +
+ +
{{ session('error') }}
+
+ @endif + + +
+
+ +
+
+ +
+ +
+ + +
+ +
+ + + + @if(request()->hasAny(['q', 'collection_id'])) + + Filters wissen + + @endif +
+ +
+ + {{ $totalStaged }} concepten wachten op beoordeling + +
+
+ + +
+
+ + +
+ +
+ @csrf + + +
+ + +
+ +
+ + +
+ + +
+ +
+ @csrf + + +
+
+ + + @if($stagedProducts->isNotEmpty()) +
+ + Pagina {{ $stagedProducts->currentPage() }} van {{ $stagedProducts->lastPage() }} +
+ @endif + + + @if($stagedProducts->isEmpty()) +
+
+ +
+

Geen concepten in de wachtrij!

+

+ Alle binnengehaalde modellen zijn al beoordeeld en gepubliceerd, of er zijn nog geen nieuwe modellen toegevoegd aan je gevolgde collecties. +

+ +
+ @else +
+ @foreach($stagedProducts as $product) + @php + $printInfo = $product->print_info ?? []; + @endphp +
+ + +
+
+ {{ $product->name }} + + +
+ +
+ + +
+ + Concept + + @if($product->makerworldCollection) + + {{ $product->makerworldCollection->name }} + + @endif +
+ + + + + +
+ + + @if(!empty($product->gallery_images) && count($product->gallery_images) > 0) +
+ Foto's: + + @foreach(array_slice($product->gallery_images, 0, 6) as $galleryPic) + + @endforeach + @if(count($product->gallery_images) > 6) + +{{ count($product->gallery_images) - 6 }} meer + @endif +
+ @endif + + +
+
+ Printtijd + + {{ $product->print_time_hours ? $product->print_time_hours . ' uur' : '-' }} + +
+
+ Gewicht + + {{ $product->weight_grams ? $product->weight_grams . ' gram' : '-' }} + +
+
+ Adviesprijs + + {{ $product->formatted_price }} + +
+
+ + +
+
+ Maker: {{ $product->makerworld_author ?? 'MakerWorld Maker' }} + #{{ $product->makerworld_id }} +
+
+
+ + +
+ @csrf + + +
+ + +
+ +
+ +
+ + +
+ + +
+ + +
+
+ + +
+ + + +
+
+ + + +
+ @endforeach +
+ + +
+ {{ $stagedProducts->links() }} +
+ @endif + +
+
+ + +@endsection diff --git a/resources/views/layouts/admin.blade.php b/resources/views/layouts/admin.blade.php index 59a5c98..13b13f4 100644 --- a/resources/views/layouts/admin.blade.php +++ b/resources/views/layouts/admin.blade.php @@ -44,6 +44,19 @@ Producten + @php + $draftBadgeCount = \App\Models\Product::where('status', 'draft')->count(); + @endphp + + + Artikel Beheer + @if($draftBadgeCount > 0) + + {{ $draftBadgeCount }} + + @endif + + Categorieën @@ -54,9 +67,9 @@ Bestellingen - + - MakerWorld + MakerWorld Sync
@@ -85,6 +98,14 @@ 📦 Producten + + ⚡ Artikel Beheer (Geïmporteerd) + @if(($draftBadgeCount ?? 0) > 0) + + {{ $draftBadgeCount }} nieuw + + @endif + 📁 Categorieën diff --git a/routes/console.php b/routes/console.php index 3c9adf1..d015ed8 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,14 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +// Automatische MakerWorld Collecties Synchronisatie iedere 10 minuten +Schedule::command('app:sync-makerworld-collections') + ->everyTenMinutes() + ->withoutOverlapping() + ->runInBackground(); diff --git a/routes/web.php b/routes/web.php index 2c9f36f..e795604 100644 --- a/routes/web.php +++ b/routes/web.php @@ -127,7 +127,7 @@ Route::prefix('admin')->name('admin.')->middleware(['auth', 'admin'])->group(fun Route::get('/orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show'); Route::post('/orders/{order}/status', [AdminOrderController::class, 'updateStatus'])->name('orders.status'); - // MakerWorld Importer + // MakerWorld Importer & Collecties Auto-Sync Route::get('/makerworld', [MakerWorldController::class, 'index'])->name('makerworld.index'); Route::post('/makerworld/login', [MakerWorldController::class, 'login'])->name('makerworld.login'); Route::post('/makerworld/verify-code', [MakerWorldController::class, 'verifyCode'])->name('makerworld.verify-code'); @@ -136,4 +136,17 @@ Route::prefix('admin')->name('admin.')->middleware(['auth', 'admin'])->group(fun Route::get('/makerworld/preview', [MakerWorldController::class, 'preview'])->name('makerworld.preview'); Route::post('/makerworld/import', [MakerWorldController::class, 'import'])->name('makerworld.import'); Route::post('/makerworld/batch-import', [MakerWorldController::class, 'batchImport'])->name('makerworld.batch-import'); + + // Collecties Auto-Sync Management + Route::post('/makerworld/collections/add', [MakerWorldController::class, 'addCollection'])->name('makerworld.collections.add'); + Route::post('/makerworld/collections/{collection}/toggle', [MakerWorldController::class, 'toggleCollectionSync'])->name('makerworld.collections.toggle'); + Route::post('/makerworld/collections/{collection}/sync', [MakerWorldController::class, 'syncCollectionNow'])->name('makerworld.collections.sync'); + Route::delete('/makerworld/collections/{collection}', [MakerWorldController::class, 'deleteCollection'])->name('makerworld.collections.delete'); + + // Artikel Beheer (Staging & Curation voor geïmporteerde modellen) + Route::get('/makerworld/staging', [MakerWorldController::class, 'staging'])->name('makerworld.staging'); + Route::post('/makerworld/staging/{product}/publish', [MakerWorldController::class, 'publishStaged'])->name('makerworld.staging.publish'); + Route::post('/makerworld/staging/bulk-publish', [MakerWorldController::class, 'bulkPublishStaged'])->name('makerworld.staging.bulk-publish'); + Route::delete('/makerworld/staging/{product}', [MakerWorldController::class, 'rejectStaged'])->name('makerworld.staging.reject'); + Route::post('/makerworld/staging/bulk-reject', [MakerWorldController::class, 'bulkRejectStaged'])->name('makerworld.staging.bulk-reject'); }); diff --git a/tests/Feature/AuthAndEmailTest.php b/tests/Feature/AuthAndEmailTest.php index 089f5bd..efc317d 100644 --- a/tests/Feature/AuthAndEmailTest.php +++ b/tests/Feature/AuthAndEmailTest.php @@ -216,4 +216,3 @@ class AuthAndEmailTest extends TestCase }); } } - diff --git a/tests/Feature/MakerWorldCollectionSyncTest.php b/tests/Feature/MakerWorldCollectionSyncTest.php new file mode 100644 index 0000000..7cd1316 --- /dev/null +++ b/tests/Feature/MakerWorldCollectionSyncTest.php @@ -0,0 +1,290 @@ +admin = User::create([ + 'name' => 'Admin Jouwdriedee', + 'email' => 'info@jouwdriedee.nl', + 'password' => bcrypt('Jw3D!x9#QmK7$vR2'), + 'is_admin' => true, + ]); + + $this->category = Category::create([ + 'name' => 'Fidget Toys & Gadgets', + 'slug' => 'fidget-toys', + 'description' => 'Leuke fidget toys', + 'is_featured' => true, + ]); + + $this->actingAs($this->admin); + } + + protected function fakeCollectionHttp(): void + { + Http::fake([ + 'https://api.bambulab.com/v1/design-service/favorites/9205051' => Http::response([ + 'id' => 9205051, + 'title' => 'Top Populaire Modellen', + 'description' => 'Mijn favoriete collectie', + 'cover' => 'https://makerworld.bblmw.com/collection_cover.jpg', + 'designCnt' => 2, + 'designs' => [ + [ + 'id' => 1740435, + 'title' => 'Glow in the dark Fidget Slug', + 'cover' => 'https://makerworld.bblmw.com/slug_cover.jpg', + 'summary' => '

Super flexibele fidget slug.

', + 'designCreator' => [ + 'name' => 'MakerBob', + 'handle' => 'maker_bob', + ], + 'instances' => [ + [ + 'title' => '0.4mm High Speed Profile', + 'printerName' => 'Bambu Lab P1S', + 'nozzleDiameter' => '0.4 mm', + 'weight' => 75.5, + 'prediction' => 7200, // 2 hours + 'filamentConfig' => ['PLA Silk Rainbow'], + ], + ], + 'designExtension' => [ + 'design_pictures' => [ + ['url' => 'https://makerworld.bblmw.com/slug_photo_1.jpg'], + ['url' => 'https://makerworld.bblmw.com/slug_photo_2.jpg'], + ], + 'real_pictures' => [ + ['url' => 'https://makerworld.bblmw.com/slug_real_1.jpg'], + ], + ], + ], + ], + ], 200), + 'https://api.bambulab.com/v1/design-service/design/1740435' => Http::response([ + 'id' => 1740435, + 'title' => 'Glow in the dark Fidget Slug', + 'coverUrl' => 'https://makerworld.bblmw.com/slug_cover.jpg', + 'summary' => '

Super flexibele fidget slug.

', + 'license' => 'CC-BY-SA', + 'designCreator' => [ + 'name' => 'MakerBob', + 'handle' => 'maker_bob', + ], + 'instances' => [ + [ + 'title' => '0.4mm High Speed Profile', + 'printerName' => 'Bambu Lab P1S', + 'nozzleDiameter' => '0.4 mm', + 'weight' => 75.5, + 'prediction' => 7200, + ], + ], + 'designExtension' => [ + 'design_pictures' => [ + ['url' => 'https://makerworld.bblmw.com/slug_photo_1.jpg'], + ['url' => 'https://makerworld.bblmw.com/slug_photo_2.jpg'], + ], + ], + ], 200), + ]); + } + + public function test_admin_can_add_makerworld_collection(): void + { + $this->fakeCollectionHttp(); + + $response = $this->post(route('admin.makerworld.collections.add'), [ + 'collection_input' => 'https://makerworld.com/nl/collections/9205051', + 'is_active_sync' => true, + 'sync_now' => true, + ]); + + $response->assertRedirect(route('admin.makerworld.index', ['tab' => 'collections'])); + $response->assertSessionHas('success'); + + $this->assertDatabaseHas('makerworld_collections', [ + 'makerworld_collection_id' => '9205051', + 'name' => 'Top Populaire Modellen', + 'is_active_sync' => true, + ]); + } + + public function test_collection_sync_creates_draft_products_with_photos_and_print_info(): void + { + $this->fakeCollectionHttp(); + + $collection = MakerWorldCollection::create([ + 'makerworld_collection_id' => '9205051', + 'name' => 'Top Populaire Modellen', + 'is_active_sync' => true, + 'sync_interval_minutes' => 10, + ]); + + /** @var MakerWorldService $service */ + $service = app(MakerWorldService::class); + $result = $service->syncCollection($collection); + + $this->assertEquals(1, $result['imported']); + $this->assertNull($result['error']); + + // Assert database record + $product = Product::where('makerworld_id', 1740435)->first(); + $this->assertNotNull($product); + $this->assertEquals('draft', $product->status); + $this->assertFalse($product->is_active); + $this->assertEquals($collection->id, $product->makerworld_collection_id); + $this->assertEquals('https://makerworld.bblmw.com/slug_cover.jpg', $product->image_url); + $this->assertNotEmpty($product->gallery_images); + $this->assertEquals(76, $product->weight_grams); + $this->assertEquals(2.0, $product->print_time_hours); + $this->assertNotNull($product->print_info); + $this->assertEquals('0.4 mm', $product->print_info['nozzle']); + + // Assert product is NOT visible on customer storefront + $shopResponse = $this->get(route('catalog')); + $shopResponse->assertOk(); + $shopResponse->assertDontSee('Glow in the dark Fidget Slug'); + } + + public function test_artisan_sync_makerworld_collections_command(): void + { + $this->fakeCollectionHttp(); + + $collection = MakerWorldCollection::create([ + 'makerworld_collection_id' => '9205051', + 'name' => 'Top Populaire Modellen', + 'is_active_sync' => true, + 'sync_interval_minutes' => 10, + ]); + + $exitCode = Artisan::call('app:sync-makerworld-collections'); + $this->assertEquals(0, $exitCode); + + $this->assertDatabaseHas('products', [ + 'makerworld_id' => 1740435, + 'status' => 'draft', + 'makerworld_collection_id' => $collection->id, + ]); + } + + public function test_admin_can_publish_staged_product_in_artikel_beheer(): void + { + $product = Product::create([ + 'makerworld_id' => 1740435, + 'name' => 'Staged Fidget Model', + 'slug' => 'staged-fidget-model', + 'short_description' => 'Korte omschrijving', + 'description' => 'Volledige omschrijving', + 'base_price' => 12.50, + 'status' => 'draft', + 'is_active' => false, + 'category_id' => $this->category->id, + ]); + + $response = $this->post(route('admin.makerworld.staging.publish', $product), [ + 'name' => 'Gecureerde Fidget Slug Deluxe', + 'category_id' => $this->category->id, + 'base_price' => 19.95, + 'is_featured' => true, + ]); + + $response->assertRedirect(); + $response->assertSessionHas('success'); + + $product->refresh(); + $this->assertEquals('published', $product->status); + $this->assertTrue($product->is_active); + $this->assertEquals(19.95, $product->base_price); + $this->assertEquals('Gecureerde Fidget Slug Deluxe', $product->name); + + // Verify it is now visible in the customer catalog + $catalogResp = $this->get(route('catalog')); + $catalogResp->assertOk(); + $catalogResp->assertSee('Gecureerde Fidget Slug Deluxe'); + } + + public function test_admin_can_bulk_publish_staged_products(): void + { + $p1 = Product::create([ + 'makerworld_id' => 101, + 'name' => 'Concept 1', + 'slug' => 'concept-1', + 'description' => 'Test 1', + 'base_price' => 10.00, + 'status' => 'draft', + 'is_active' => false, + ]); + + $p2 = Product::create([ + 'makerworld_id' => 102, + 'name' => 'Concept 2', + 'slug' => 'concept-2', + 'description' => 'Test 2', + 'base_price' => 15.00, + 'status' => 'draft', + 'is_active' => false, + ]); + + $response = $this->post(route('admin.makerworld.staging.bulk-publish'), [ + 'product_ids' => [$p1->id, $p2->id], + 'category_id' => $this->category->id, + 'custom_markup' => 20, // 20% markup + ]); + + $response->assertRedirect(); + $response->assertSessionHas('success'); + + $p1->refresh(); + $p2->refresh(); + + $this->assertEquals('published', $p1->status); + $this->assertTrue($p1->is_active); + $this->assertEquals(12.00, $p1->base_price); // 10 + 20% + + $this->assertEquals('published', $p2->status); + $this->assertTrue($p2->is_active); + $this->assertEquals(18.00, $p2->base_price); // 15 + 20% + } + + public function test_admin_can_toggle_sync_and_delete_collection(): void + { + $collection = MakerWorldCollection::create([ + 'makerworld_collection_id' => '8888', + 'name' => 'Test Collection', + 'is_active_sync' => true, + ]); + + // Toggle sync + $toggleResp = $this->post(route('admin.makerworld.collections.toggle', $collection)); + $toggleResp->assertRedirect(); + $collection->refresh(); + $this->assertFalse($collection->is_active_sync); + + // Delete collection + $deleteResp = $this->delete(route('admin.makerworld.collections.delete', $collection)); + $deleteResp->assertRedirect(); + $this->assertDatabaseMissing('makerworld_collections', ['id' => $collection->id]); + } +}