$this->userAgent, 'Accept' => 'application/json, text/plain, */*', ]; $token = $this->getAccessToken(); if ($token) { $headers['Authorization'] = 'Bearer '.$token; } return Http::withHeaders($headers)->timeout(15); } /** * Get the stored access token. */ public function getAccessToken(): ?string { return Setting::get('makerworld_token'); } /** * Store the access token. */ public function setAccessToken(string $token): array { Setting::set('makerworld_token', trim($token)); $profile = $this->getUserProfile(); if ($profile) { Setting::set('makerworld_user', $profile); return [ 'success' => true, 'user' => $profile, ]; } return [ 'success' => true, 'user' => null, ]; } /** * Check if currently authenticated with MakerWorld. */ public function isAuthenticated(): bool { return ! empty($this->getAccessToken()); } /** * Log out from MakerWorld by clearing saved credentials. */ public function logout(): void { Setting::forget('makerworld_token'); Setting::forget('makerworld_user'); } /** * Attempt to log in with MakerWorld account credentials (email and password). * * @return array{status: string, message?: string, token?: string, user?: array, account?: string} */ public function loginWithCredentials(string $account, string $password): array { try { $response = Http::withHeaders([ 'User-Agent' => $this->userAgent, 'Content-Type' => 'application/json', ])->post("{$this->baseUrl}/v1/user-service/user/login", [ 'account' => $account, 'password' => $password, 'apiError' => '', ]); $data = $response->json(); if (! empty($data['accessToken'])) { $this->setAccessToken($data['accessToken']); return [ 'status' => 'success', 'token' => $data['accessToken'], 'message' => 'Succesvol ingelogd bij MakerWorld!', ]; } // Check if 2FA code is needed (Bambu Lab sends the code automatically when verifyCode is returned) if (isset($data['loginType']) && $data['loginType'] === 'verifyCode') { return [ 'status' => '2fa_required', 'account' => $account, 'message' => 'Verificatiecode vereist. Bambu Lab heeft een 6-cijferige verificatiecode naar je e-mail gestuurd.', ]; } $errorMsg = $data['error'] ?? $data['message'] ?? 'Inloggen mislukt. Controleer je MakerWorld account en wachtwoord.'; return [ 'status' => 'error', 'message' => $errorMsg, ]; } catch (\Throwable $e) { Log::error('MakerWorld login error: '.$e->getMessage()); return [ 'status' => 'error', 'message' => 'Verbinding met MakerWorld mislukt: '.$e->getMessage(), ]; } } /** * Request an email verification code for Bambu / MakerWorld login. */ public function sendEmailVerificationCode(string $email): bool { try { $response = Http::withHeaders([ 'User-Agent' => $this->userAgent, 'Content-Type' => 'application/json', ])->post("{$this->baseUrl}/v1/user-service/user/sendemail/code", [ 'email' => $email, 'type' => 'codeLogin', ]); return $response->successful(); } catch (\Throwable $e) { Log::error('MakerWorld send code error: '.$e->getMessage()); return false; } } /** * Complete login with account and email verification code. */ public function verifyLoginCode(string $account, string $code): array { try { $response = Http::withHeaders([ 'User-Agent' => $this->userAgent, 'Content-Type' => 'application/json', ])->post("{$this->baseUrl}/v1/user-service/user/login", [ 'account' => $account, 'code' => trim($code), ]); $data = $response->json(); if (! empty($data['accessToken'])) { $this->setAccessToken($data['accessToken']); return [ 'status' => 'success', 'token' => $data['accessToken'], 'message' => 'Verificatie geslaagd! Ingelogd bij MakerWorld.', ]; } return [ 'status' => 'error', 'message' => $data['error'] ?? $data['message'] ?? 'Ongeldige verificatiecode.', ]; } catch (\Throwable $e) { Log::error('MakerWorld verify code error: '.$e->getMessage()); return [ 'status' => 'error', 'message' => 'Verificatie mislukt: '.$e->getMessage(), ]; } } /** * Fetch user preference and profile. */ public function getUserProfile(): ?array { try { $response = $this->client()->get("{$this->baseUrl}/v1/design-user-service/my/preference"); if ($response->successful()) { return $response->json(); } } catch (\Throwable $e) { Log::warning('MakerWorld getUserProfile failed: '.$e->getMessage()); } return Setting::get('makerworld_user'); } /** * Safely extract a normalized list of model items from diverse API response formats. * * @return array> */ public function extractModelsList(mixed $data): array { if (! is_array($data)) { return []; } if (isset($data['data']) && is_array($data['data'])) { $data = $data['data']; } $list = $data['hits'] ?? $data['designs'] ?? $data['list'] ?? $data['items'] ?? null; if (is_array($list)) { return array_values(array_filter($list, fn ($item) => is_array($item))); } if (array_is_list($data)) { return array_values(array_filter($data, fn ($item) => is_array($item))); } return []; } /** * Fetch user's favorited models from MakerWorld. */ public function getFavorites(int $page = 1, int $limit = 24): array { if (! $this->isAuthenticated()) { return []; } try { $response = $this->client()->get("{$this->baseUrl}/v1/design-service/my/design/favoriteslist", [ 'page' => $page, 'limit' => $limit, ]); if ($response->successful()) { return $this->extractModelsList($response->json()); } } catch (\Throwable $e) { Log::error('MakerWorld getFavorites error: '.$e->getMessage()); } return []; } /** * Fetch user's published models from MakerWorld. */ public function getPublished(int $page = 1, int $limit = 24): array { if (! $this->isAuthenticated()) { return []; } try { $response = $this->client()->get("{$this->baseUrl}/v1/design-service/my/design/published", [ 'page' => $page, 'limit' => $limit, ]); if ($response->successful()) { return $this->extractModelsList($response->json()); } } catch (\Throwable $e) { Log::error('MakerWorld getPublished error: '.$e->getMessage()); } return []; } /** * Fetch popular showcase/contest models from MakerWorld. */ public function getCommunityShowcase(int $limit = 30): array { try { $response = $this->client()->get("{$this->baseUrl}/v1/design-service/contest/list", [ 'limit' => 3, ]); if ($response->successful()) { $data = $response->json(); $allDesigns = []; if (! empty($data['hits']) && is_array($data['hits'])) { foreach ($data['hits'] as $contest) { if (! empty($contest['designs']) && is_array($contest['designs'])) { foreach ($contest['designs'] as $design) { if (is_array($design) && ! empty($design['id'])) { $allDesigns[$design['id']] = $design; } } } } } return array_slice(array_values($allDesigns), 0, $limit); } } catch (\Throwable $e) { Log::error('MakerWorld getCommunityShowcase error: '.$e->getMessage()); } return []; } /** * Get detailed design info by ID. */ public function getDesignDetails(int|string $designId): ?array { try { $response = $this->client()->get("{$this->baseUrl}/v1/design-service/design/{$designId}"); if ($response->successful()) { $data = $response->json(); if (! empty($data['id']) || ! empty($data['title'])) { return $data; } } } catch (\Throwable $e) { Log::error("MakerWorld getDesignDetails({$designId}) error: ".$e->getMessage()); } return null; } /** * Get batch of designs by IDs. */ public function getDesignsByIds(array $designIds): array { if (empty($designIds)) { return []; } try { $ids = implode(',', array_filter(array_map('intval', $designIds))); $response = $this->client()->get("{$this->baseUrl}/v1/design-service/design/ids", [ 'designIds' => $ids, ]); if ($response->successful()) { $data = $response->json(); return $data['designs'] ?? []; } } catch (\Throwable $e) { Log::error('MakerWorld getDesignsByIds error: '.$e->getMessage()); } return []; } /** * Parse model ID from full MakerWorld URL or raw ID string. * Examples: * - https://makerworld.com/nl/models/55866#profileId-60000 -> 55866 * - https://makerworld.com/en/models/12345?keyword=test -> 12345 * - 55866 -> 55866 */ public function parseModelId(string $input): ?int { $input = trim($input); if (is_numeric($input)) { return (int) $input; } if (preg_match('/models\/(\d+)/i', $input, $matches)) { return (int) $matches[1]; } if (preg_match('/design\/(\d+)/i', $input, $matches)) { return (int) $matches[1]; } 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(); $list = $this->extractModelsList($data); if (! empty($list)) { return $list; } } } catch (\Throwable $e) { Log::error("MakerWorld getCollectionDesigns({$collectionId}) error: ".$e->getMessage()); } // If authenticated and this represents the user collection or favorites: if ($this->isAuthenticated()) { $userFavorites = $this->getFavorites($page, $limit); if (! empty($userFavorites)) { return $userFavorites; } $userPublished = $this->getPublished($page, $limit); if (! empty($userPublished)) { return $userPublished; } } // Fallback to MakerWorld showcase models so the user always receives models in Artikel Beheer! return $this->getCommunityShowcase($limit); } /** * Transform raw MakerWorld API design details into a clean format for product creation. */ public function formatDesignForImport(array $design): array { $designId = (int) ($design['id'] ?? 0); $title = trim($design['title'] ?? 'MakerWorld 3D Model #'.$designId); // Summary / Description cleanup $rawSummary = $design['summary'] ?? $design['summaryTranslated'] ?? ''; $cleanDescription = $this->sanitizeHtml($rawSummary); $shortDescription = Str::limit(strip_tags($rawSummary), 280); // Cover & Gallery Images $coverUrl = $design['coverUrl'] ?? $design['cover'] ?? null; $gallery = []; 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 && ! in_array($pic['url'], $gallery, true)) { $gallery[] = $pic['url']; } } } if (! empty($design['designExtension']['real_pictures']) && is_array($design['designExtension']['real_pictures'])) { foreach ($design['designExtension']['real_pictures'] as $pic) { if (! empty($pic['url']) && $pic['url'] !== $coverUrl && ! in_array($pic['url'], $gallery, true)) { $gallery[] = $pic['url']; } } } // 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]; if (! empty($profile['weight'])) { $weightGrams = (int) round($profile['weight']); } if (! empty($profile['prediction'])) { $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 // Base starting price of 9.95, + ~0.08 per gram + 1.25 per print hour $suggestedPrice = 14.95; if ($weightGrams || $printTimeHours) { $cost = 5.0; if ($weightGrams) { $cost += ($weightGrams * 0.07); } if ($printTimeHours) { $cost += ($printTimeHours * 1.50); } $suggestedPrice = round(max(9.95, $cost * 1.6), 2); } // Creator and MakerWorld URL $authorName = $design['designCreator']['name'] ?? $design['designCreator']['handle'] ?? 'MakerWorld Creator'; $makerworldUrl = "https://makerworld.com/nl/models/{$designId}"; $license = $design['license'] ?? 'Standard MakerWorld License'; return [ 'makerworld_id' => $designId, 'name' => $title, 'slug' => Str::slug($title), 'short_description' => $shortDescription, 'description' => $cleanDescription, 'base_price' => $suggestedPrice, 'sku' => 'MW-'.$designId, 'is_print_on_demand' => true, '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' => $gallery, 'available_materials' => $materials, 'available_colors' => ['Mat Zwart', 'Sneeuwwit', 'Leisteen Grijs', 'Koningsblauw'], ]; } /** * Import or update a product from MakerWorld data. */ public function importDesign( array $designData, ?int $categoryId = null, ?float $customPrice = null, ?string $customName = null, bool $isActive = true, string $status = 'published', ?int $collectionId = null ): Product { $formatted = $this->formatDesignForImport($designData); if ($customPrice !== null && $customPrice > 0) { $formatted['base_price'] = $customPrice; } if ($customName !== null && trim($customName) !== '') { $formatted['name'] = trim($customName); $formatted['slug'] = Str::slug($formatted['name']); } if ($categoryId !== null) { $formatted['category_id'] = $categoryId; } $formatted['is_active'] = $isActive; $formatted['status'] = $status; if ($collectionId !== null) { $formatted['makerworld_collection_id'] = $collectionId; } // Ensure slug is unique $baseSlug = $formatted['slug']; $count = 1; while (Product::where('slug', $formatted['slug'])->where('makerworld_id', '!=', $formatted['makerworld_id'])->exists()) { $formatted['slug'] = "{$baseSlug}-{$count}"; $count++; } return Product::updateOrCreate( ['makerworld_id' => $formatted['makerworld_id']], $formatted ); } /** * 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. */ protected function sanitizeHtml(string $html): string { $allowed = '


    1. '; return strip_tags($html, $allowed); } }