Update
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Product;
|
||||
use App\Services\MakerWorldService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class MakerWorldController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
public MakerWorldService $makerWorld
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display the MakerWorld importer dashboard.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$isAuthenticated = $this->makerWorld->isAuthenticated();
|
||||
$userProfile = $this->makerWorld->getUserProfile();
|
||||
|
||||
$tab = $request->query('tab', $isAuthenticated ? 'favorites' : 'community');
|
||||
$models = [];
|
||||
|
||||
if ($tab === 'favorites' && $isAuthenticated) {
|
||||
$models = $this->makerWorld->getFavorites();
|
||||
} elseif ($tab === 'published' && $isAuthenticated) {
|
||||
$models = $this->makerWorld->getPublished();
|
||||
} elseif ($tab === 'community') {
|
||||
$models = $this->makerWorld->getCommunityShowcase();
|
||||
}
|
||||
|
||||
$importedProducts = Product::with('category')
|
||||
->whereNotNull('makerworld_id')
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
$importedIds = Product::whereNotNull('makerworld_id')
|
||||
->pluck('makerworld_id')
|
||||
->toArray();
|
||||
|
||||
$categories = Category::orderBy('name')->get();
|
||||
|
||||
return view('admin.makerworld.index', [
|
||||
'isAuthenticated' => $isAuthenticated,
|
||||
'userProfile' => $userProfile,
|
||||
'currentTab' => $tab,
|
||||
'models' => $models,
|
||||
'importedProducts' => $importedProducts,
|
||||
'importedIds' => $importedIds,
|
||||
'categories' => $categories,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with MakerWorld credentials.
|
||||
*/
|
||||
public function login(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'account' => 'required|string',
|
||||
'password' => 'required|string',
|
||||
]);
|
||||
|
||||
$result = $this->makerWorld->loginWithCredentials(
|
||||
$validated['account'],
|
||||
$validated['password']
|
||||
);
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
return redirect()
|
||||
->route('admin.makerworld.index', ['tab' => 'favorites'])
|
||||
->with('success', $result['message']);
|
||||
}
|
||||
|
||||
if ($result['status'] === '2fa_required') {
|
||||
return redirect()
|
||||
->route('admin.makerworld.index')
|
||||
->with('warning', $result['message'])
|
||||
->with('makerworld_2fa_account', $result['account']);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.makerworld.index')
|
||||
->with('error', $result['message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit email verification code for 2FA.
|
||||
*/
|
||||
public function verifyCode(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'account' => 'required|string',
|
||||
'code' => 'required|string|size:6',
|
||||
]);
|
||||
|
||||
$result = $this->makerWorld->verifyLoginCode(
|
||||
$validated['account'],
|
||||
$validated['code']
|
||||
);
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
return redirect()
|
||||
->route('admin.makerworld.index', ['tab' => 'favorites'])
|
||||
->with('success', $result['message']);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.makerworld.index')
|
||||
->with('error', $result['message'])
|
||||
->with('makerworld_2fa_account', $validated['account']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save MakerWorld session / bearer token directly.
|
||||
*/
|
||||
public function saveToken(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'token' => 'required|string|min:10',
|
||||
]);
|
||||
|
||||
$this->makerWorld->setAccessToken($validated['token']);
|
||||
|
||||
return redirect()
|
||||
->route('admin.makerworld.index', ['tab' => 'favorites'])
|
||||
->with('success', 'MakerWorld token succesvol opgeslagen en verbonden!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect MakerWorld account.
|
||||
*/
|
||||
public function disconnect(): RedirectResponse
|
||||
{
|
||||
$this->makerWorld->logout();
|
||||
|
||||
return redirect()
|
||||
->route('admin.makerworld.index', ['tab' => 'community'])
|
||||
->with('success', 'MakerWorld account succesvol ontkoppeld.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajax preview endpoint for single model by URL or ID.
|
||||
*/
|
||||
public function preview(Request $request): JsonResponse
|
||||
{
|
||||
$input = $request->query('query');
|
||||
|
||||
if (! $input) {
|
||||
return response()->json(['error' => 'Geen model URL of ID opgegeven.'], 400);
|
||||
}
|
||||
|
||||
$id = $this->makerWorld->parseModelId($input);
|
||||
|
||||
if (! $id) {
|
||||
return response()->json(['error' => 'Ongeldige MakerWorld URL of model-ID.'], 400);
|
||||
}
|
||||
|
||||
$details = $this->makerWorld->getDesignDetails($id);
|
||||
|
||||
if (! $details) {
|
||||
return response()->json(['error' => "Model #{$id} kon niet worden opgehaald bij MakerWorld."], 404);
|
||||
}
|
||||
|
||||
$formatted = $this->makerWorld->formatDesignForImport($details);
|
||||
$alreadyImported = Product::where('makerworld_id', $id)->first();
|
||||
|
||||
return response()->json([
|
||||
'model' => $formatted,
|
||||
'raw' => $details,
|
||||
'is_imported' => (bool) $alreadyImported,
|
||||
'existing_product' => $alreadyImported,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a single model into shop.
|
||||
*/
|
||||
public function import(Request $request): RedirectResponse|JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'makerworld_id' => 'required|integer',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'custom_price' => 'nullable|numeric|min:0.5',
|
||||
'custom_name' => 'nullable|string|max:255',
|
||||
'is_active' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$details = $this->makerWorld->getDesignDetails($validated['makerworld_id']);
|
||||
|
||||
if (! $details) {
|
||||
$msg = "Model #{$validated['makerworld_id']} kon niet worden opgehaald.";
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['error' => $msg], 404);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
$product = $this->makerWorld->importDesign(
|
||||
$details,
|
||||
$validated['category_id'] ?? null,
|
||||
$validated['custom_price'] ?? null,
|
||||
$validated['custom_name'] ?? null,
|
||||
$request->boolean('is_active', true)
|
||||
);
|
||||
|
||||
$msg = "Model \"{$product->name}\" is succesvol toegevoegd aan je webshop!";
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $msg,
|
||||
'product' => $product,
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', $msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch import multiple selected models.
|
||||
*/
|
||||
public function batchImport(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ids' => 'required|array',
|
||||
'ids.*' => 'integer',
|
||||
'category_id' => 'nullable|exists:categories,id',
|
||||
'is_active' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$count = 0;
|
||||
$isActive = $request->boolean('is_active', true);
|
||||
$categoryId = $validated['category_id'] ?? null;
|
||||
|
||||
foreach ($validated['ids'] as $id) {
|
||||
$details = $this->makerWorld->getDesignDetails($id);
|
||||
if ($details) {
|
||||
$this->makerWorld->importDesign(
|
||||
$details,
|
||||
$categoryId,
|
||||
null,
|
||||
null,
|
||||
$isActive
|
||||
);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', "{$count} modellen succesvol geïmporteerd naar je webshop!");
|
||||
}
|
||||
}
|
||||
@@ -54,12 +54,12 @@ class CartController extends Controller
|
||||
$unitPrice = round($unitPrice, 2);
|
||||
|
||||
// Unieke sleutel in sessie op basis van product + configuratie
|
||||
$cartKey = md5($product->id . '_' . $validated['material'] . '_' . $validated['color'] . '_' . $scale);
|
||||
$cartKey = md5($product->id.'_'.$validated['material'].'_'.$validated['color'].'_'.$scale);
|
||||
|
||||
$cart = session()->get('cart', []);
|
||||
|
||||
if (isset($cart[$cartKey])) {
|
||||
$cart[$cartKey]['quantity'] += (int)$validated['quantity'];
|
||||
$cart[$cartKey]['quantity'] += (int) $validated['quantity'];
|
||||
} else {
|
||||
$cart[$cartKey] = [
|
||||
'id' => $product->id,
|
||||
@@ -72,7 +72,7 @@ class CartController extends Controller
|
||||
'scale' => $scale,
|
||||
'infill' => $validated['infill'] ?? 'Standaard (20%)',
|
||||
'price' => $unitPrice,
|
||||
'quantity' => (int)$validated['quantity'],
|
||||
'quantity' => (int) $validated['quantity'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ class CartController extends Controller
|
||||
$cart = session()->get('cart', []);
|
||||
|
||||
if (isset($cart[$validated['key']])) {
|
||||
$cart[$validated['key']]['quantity'] = (int)$validated['quantity'];
|
||||
$cart[$validated['key']]['quantity'] = (int) $validated['quantity'];
|
||||
session()->put('cart', $cart);
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ class CheckoutController extends Controller
|
||||
$shipping = $subtotal > 50 ? 0.00 : 4.95;
|
||||
$total = $subtotal + $shipping;
|
||||
|
||||
$orderNumber = 'J3D-' . date('Y') . '-' . strtoupper(Str::random(6));
|
||||
$orderNumber = 'J3D-'.date('Y').'-'.strtoupper(Str::random(6));
|
||||
|
||||
$order = Order::create([
|
||||
'order_number' => $orderNumber,
|
||||
|
||||
@@ -51,8 +51,8 @@ class ShopController extends Controller
|
||||
$search = $request->q;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('short_description', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
->orWhere('short_description', 'like', "%{$search}%")
|
||||
->orWhere('description', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,6 @@ class Order extends Model
|
||||
|
||||
public function getFormattedTotalAttribute(): string
|
||||
{
|
||||
return '€ ' . number_format($this->total, 2, ',', '.');
|
||||
return '€ '.number_format($this->total, 2, ',', '.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ class Product extends Model
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'makerworld_id',
|
||||
'category_id',
|
||||
'name',
|
||||
'slug',
|
||||
@@ -27,12 +28,16 @@ class Product extends Model
|
||||
'weight_grams',
|
||||
'print_time_hours',
|
||||
'image_url',
|
||||
'makerworld_url',
|
||||
'makerworld_author',
|
||||
'makerworld_license',
|
||||
'gallery_images',
|
||||
'available_materials',
|
||||
'available_colors',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'makerworld_id' => 'integer',
|
||||
'base_price' => 'float',
|
||||
'is_print_on_demand' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
@@ -55,6 +60,6 @@ class Product extends Model
|
||||
|
||||
public function getFormattedPriceAttribute(): string
|
||||
{
|
||||
return '€ ' . number_format($this->base_price, 2, ',', '.');
|
||||
return '€ '.number_format($this->base_price, 2, ',', '.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Setting extends Model
|
||||
{
|
||||
protected $primaryKey = 'key';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'value',
|
||||
];
|
||||
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$setting = static::query()->find($key);
|
||||
|
||||
if (! $setting || $setting->value === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$decoded = json_decode($setting->value, true);
|
||||
|
||||
return (json_last_error() === JSON_ERROR_NONE) ? $decoded : $setting->value;
|
||||
}
|
||||
|
||||
public static function set(string $key, mixed $value): static
|
||||
{
|
||||
$encoded = is_array($value) || is_object($value) ? json_encode($value) : (string) $value;
|
||||
|
||||
return static::query()->updateOrCreate(
|
||||
['key' => $key],
|
||||
['value' => $encoded]
|
||||
);
|
||||
}
|
||||
|
||||
public static function forget(string $key): bool
|
||||
{
|
||||
return (bool) static::query()->where('key', $key)->delete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class MakerWorldService
|
||||
{
|
||||
protected string $baseUrl = 'https://api.bambulab.com';
|
||||
|
||||
protected string $userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
|
||||
|
||||
/**
|
||||
* Build an HTTP client pre-configured with headers and optional bearer token.
|
||||
*/
|
||||
protected function client(): PendingRequest
|
||||
{
|
||||
$headers = [
|
||||
'User-Agent' => $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 = '<p><br><strong><b><em><i><u><ul><ol><li><h3><h4><h5><blockquote>';
|
||||
|
||||
return strip_tags($html, $allowed);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user