$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 if (isset($data['loginType']) && $data['loginType'] === 'verifyCode') { $this->sendEmailVerificationCode($account); return [ 'status' => '2fa_required', 'account' => $account, 'message' => 'Verificatiecode vereist. We hebben een 6-cijferige code 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'); } /** * 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()) { $data = $response->json(); return $data['hits'] ?? $data['designs'] ?? $data['list'] ?? (is_array($data) ? $data : []); } } 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()) { $data = $response->json(); return $data['hits'] ?? $data['designs'] ?? $data['list'] ?? (is_array($data) ? $data : []); } } 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) { $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; } /** * 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) { $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 (first/default instance) $weightGrams = null; $printTimeHours = null; $materials = ['pla', 'petg']; 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); } } // 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, 'weight_grams' => $weightGrams, 'print_time_hours' => $printTimeHours, 'image_url' => $coverUrl, 'makerworld_url' => $makerworldUrl, 'makerworld_author' => $authorName, 'makerworld_license' => $license, 'gallery_images' => array_slice($gallery, 0, 8), '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 ): 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; // 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 ); } /** * Basic sanitization of MakerWorld HTML descriptions. */ protected function sanitizeHtml(string $html): string { $allowed = '