Implement MakerWorld collections sync every 10 minutes and Artikel Beheer staging workflow

This commit is contained in:
2026-11-01 15:39:10 +01:00
parent 121ff624de
commit 2d78d80934
20 changed files with 1984 additions and 675 deletions
@@ -0,0 +1,77 @@
<?php
namespace App\Console\Commands;
use App\Models\MakerWorldCollection;
use App\Services\MakerWorldService;
use Illuminate\Console\Command;
class SyncMakerWorldCollectionsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:sync-makerworld-collections
{--collection= : Specifieke collectie ID (MakerWorld ID of database ID)}
{--all : Synchroniseer alle collecties, ook niet-actieve}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Controleert MakerWorld collecties en haalt nieuwe 3D modellen automatisch binnen als concept.';
/**
* Execute the console command.
*/
public function handle(MakerWorldService $makerWorld): int
{
$this->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;
}
}
@@ -68,4 +68,3 @@ class AccountController extends Controller
return back()->with('success', 'Jouw wachtwoord is succesvol gewijzigd.'); return back()->with('success', 'Jouw wachtwoord is succesvol gewijzigd.');
} }
} }
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Category; use App\Models\Category;
use App\Models\MakerWorldCollection;
use App\Models\Product; use App\Models\Product;
use App\Services\MakerWorldService; use App\Services\MakerWorldService;
use Illuminate\Http\JsonResponse; 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 public function index(Request $request): View
{ {
$isAuthenticated = $this->makerWorld->isAuthenticated(); $isAuthenticated = $this->makerWorld->isAuthenticated();
$userProfile = $this->makerWorld->getUserProfile(); $userProfile = $this->makerWorld->getUserProfile();
$tab = $request->query('tab', $isAuthenticated ? 'favorites' : 'community'); $tab = $request->query('tab', 'collections');
$models = []; $models = [];
$apiCollections = [];
if ($tab === 'favorites' && $isAuthenticated) { if ($tab === 'collections') {
if ($isAuthenticated) {
$apiCollections = $this->makerWorld->getFavoritesCollections();
}
} elseif ($tab === 'favorites' && $isAuthenticated) {
$models = $this->makerWorld->getFavorites(); $models = $this->makerWorld->getFavorites();
} elseif ($tab === 'published' && $isAuthenticated) { } elseif ($tab === 'published' && $isAuthenticated) {
$models = $this->makerWorld->getPublished(); $models = $this->makerWorld->getPublished();
@@ -36,15 +42,11 @@ class MakerWorldController extends Controller
$models = $this->makerWorld->getCommunityShowcase(); $models = $this->makerWorld->getCommunityShowcase();
} }
$importedProducts = Product::with('category') $trackedCollections = MakerWorldCollection::withCount(['products', 'draftProducts', 'publishedProducts'])
->whereNotNull('makerworld_id')
->latest() ->latest()
->paginate(15); ->get();
$importedIds = Product::whereNotNull('makerworld_id')
->pluck('makerworld_id')
->toArray();
$stagedCount = Product::where('status', 'draft')->count();
$categories = Category::orderBy('name')->get(); $categories = Category::orderBy('name')->get();
return view('admin.makerworld.index', [ return view('admin.makerworld.index', [
@@ -52,12 +54,266 @@ class MakerWorldController extends Controller
'userProfile' => $userProfile, 'userProfile' => $userProfile,
'currentTab' => $tab, 'currentTab' => $tab,
'models' => $models, 'models' => $models,
'importedProducts' => $importedProducts, 'apiCollections' => $apiCollections,
'importedIds' => $importedIds, 'trackedCollections' => $trackedCollections,
'stagedCount' => $stagedCount,
'categories' => $categories, '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. * Authenticate with MakerWorld credentials.
*/ */
@@ -75,7 +331,7 @@ class MakerWorldController extends Controller
if ($result['status'] === 'success') { if ($result['status'] === 'success') {
return redirect() return redirect()
->route('admin.makerworld.index', ['tab' => 'favorites']) ->route('admin.makerworld.index', ['tab' => 'collections'])
->with('success', $result['message']); ->with('success', $result['message']);
} }
@@ -108,7 +364,7 @@ class MakerWorldController extends Controller
if ($result['status'] === 'success') { if ($result['status'] === 'success') {
return redirect() return redirect()
->route('admin.makerworld.index', ['tab' => 'favorites']) ->route('admin.makerworld.index', ['tab' => 'collections'])
->with('success', $result['message']); ->with('success', $result['message']);
} }
@@ -130,7 +386,7 @@ class MakerWorldController extends Controller
$this->makerWorld->setAccessToken($validated['token']); $this->makerWorld->setAccessToken($validated['token']);
return redirect() return redirect()
->route('admin.makerworld.index', ['tab' => 'favorites']) ->route('admin.makerworld.index', ['tab' => 'collections'])
->with('success', 'MakerWorld token succesvol opgeslagen en verbonden!'); ->with('success', 'MakerWorld token succesvol opgeslagen en verbonden!');
} }
@@ -142,7 +398,7 @@ class MakerWorldController extends Controller
$this->makerWorld->logout(); $this->makerWorld->logout();
return redirect() return redirect()
->route('admin.makerworld.index', ['tab' => 'community']) ->route('admin.makerworld.index', ['tab' => 'collections'])
->with('success', 'MakerWorld account succesvol ontkoppeld.'); ->with('success', 'MakerWorld account succesvol ontkoppeld.');
} }
@@ -191,6 +447,7 @@ class MakerWorldController extends Controller
'custom_price' => 'nullable|numeric|min:0.5', 'custom_price' => 'nullable|numeric|min:0.5',
'custom_name' => 'nullable|string|max:255', 'custom_name' => 'nullable|string|max:255',
'is_active' => 'nullable|boolean', 'is_active' => 'nullable|boolean',
'status' => 'nullable|string|in:draft,published',
]); ]);
$details = $this->makerWorld->getDesignDetails($validated['makerworld_id']); $details = $this->makerWorld->getDesignDetails($validated['makerworld_id']);
@@ -204,15 +461,18 @@ class MakerWorldController extends Controller
return redirect()->back()->with('error', $msg); return redirect()->back()->with('error', $msg);
} }
$status = $validated['status'] ?? ($request->boolean('is_active', true) ? 'published' : 'draft');
$product = $this->makerWorld->importDesign( $product = $this->makerWorld->importDesign(
$details, $details,
$validated['category_id'] ?? null, $validated['category_id'] ?? null,
$validated['custom_price'] ?? null, $validated['custom_price'] ?? null,
$validated['custom_name'] ?? 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()) { if ($request->wantsJson()) {
return response()->json([ return response()->json([
@@ -249,12 +509,13 @@ class MakerWorldController extends Controller
$categoryId, $categoryId,
null, null,
null, null,
$isActive $isActive,
$isActive ? 'published' : 'draft'
); );
$count++; $count++;
} }
} }
return redirect()->back()->with('success', "{$count} modellen succesvol geïmporteerd naar je webshop!"); return redirect()->back()->with('success', "{$count} modellen succesvol geïmporteerd!");
} }
} }
@@ -122,4 +122,3 @@ class ForgotPasswordController extends Controller
->with('success', 'Jouw wachtwoord is succesvol gewijzigd! Je bent direct ingelogd.'); ->with('success', 'Jouw wachtwoord is succesvol gewijzigd! Je bent direct ingelogd.');
} }
} }
@@ -70,4 +70,3 @@ class LoginController extends Controller
->with('success', 'Je bent succesvol uitgelogd.'); ->with('success', 'Je bent succesvol uitgelogd.');
} }
} }
@@ -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}."); ->with('success', "Welkom bij Jouwdriedee.nl, {$user->name}! Je account is aangemaakt en er is een bevestiging gestuurd naar {$user->email}.");
} }
} }
+5 -5
View File
@@ -19,7 +19,7 @@ class ShopController extends Controller
->get(); ->get();
$featuredProducts = Product::with('category') $featuredProducts = Product::with('category')
->where('is_active', true) ->published()
->where('is_featured', true) ->where('is_featured', true)
->latest() ->latest()
->take(6) ->take(6)
@@ -32,7 +32,7 @@ class ShopController extends Controller
public function catalog(Request $request) public function catalog(Request $request)
{ {
$query = Product::with('category')->where('is_active', true); $query = Product::with('category')->published();
// Filter op categorie // Filter op categorie
if ($request->filled('category')) { if ($request->filled('category')) {
@@ -75,13 +75,13 @@ class ShopController extends Controller
public function show($slug) 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'); $allMaterials = Material::where('is_active', true)->get()->keyBy('code');
$allColors = Color::where('in_stock', true)->get(); $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('category_id', $product->category_id)
->where('is_active', true)
->take(3) ->take(3)
->get(); ->get();
@@ -26,4 +26,3 @@ class EnsureUserIsAdmin
return $next($request); return $next($request);
} }
} }
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class MakerWorldCollection extends Model
{
use HasFactory;
protected $table = 'makerworld_collections';
protected $fillable = [
'makerworld_collection_id',
'name',
'slug',
'description',
'cover_url',
'user_handle',
'source_url',
'is_active_sync',
'sync_interval_minutes',
'items_count',
'last_synced_at',
'last_sync_status',
'last_sync_message',
'raw_metadata',
];
protected $casts = [
'is_active_sync' => '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');
}
}
+24
View File
@@ -2,6 +2,7 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -13,6 +14,7 @@ class Product extends Model
protected $fillable = [ protected $fillable = [
'makerworld_id', 'makerworld_id',
'makerworld_collection_id',
'category_id', 'category_id',
'name', 'name',
'slug', 'slug',
@@ -24,9 +26,11 @@ class Product extends Model
'stock', 'stock',
'is_featured', 'is_featured',
'is_active', 'is_active',
'status',
'dimensions', 'dimensions',
'weight_grams', 'weight_grams',
'print_time_hours', 'print_time_hours',
'print_info',
'image_url', 'image_url',
'makerworld_url', 'makerworld_url',
'makerworld_author', 'makerworld_author',
@@ -43,6 +47,7 @@ class Product extends Model
'is_featured' => 'boolean', 'is_featured' => 'boolean',
'is_active' => 'boolean', 'is_active' => 'boolean',
'print_time_hours' => 'float', 'print_time_hours' => 'float',
'print_info' => 'array',
'gallery_images' => 'array', 'gallery_images' => 'array',
'available_materials' => 'array', 'available_materials' => 'array',
'available_colors' => 'array', 'available_colors' => 'array',
@@ -53,11 +58,30 @@ class Product extends Model
return $this->belongsTo(Category::class); return $this->belongsTo(Category::class);
} }
public function makerworldCollection(): BelongsTo
{
return $this->belongsTo(MakerWorldCollection::class, 'makerworld_collection_id');
}
public function orderItems(): HasMany public function orderItems(): HasMany
{ {
return $this->hasMany(OrderItem::class); 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 public function getFormattedPriceAttribute(): string
{ {
return '€ '.number_format((float) ($this->base_price ?? 0), 2, ',', '.'); return '€ '.number_format((float) ($this->base_price ?? 0), 2, ',', '.');
+291 -4
View File
@@ -2,6 +2,7 @@
namespace App\Services; namespace App\Services;
use App\Models\MakerWorldCollection;
use App\Models\Product; use App\Models\Product;
use App\Models\Setting; use App\Models\Setting;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
@@ -404,6 +405,160 @@ class MakerWorldService
return null; 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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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. * 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'])) { if (! empty($design['designExtension']['design_pictures']) && is_array($design['designExtension']['design_pictures'])) {
foreach ($design['designExtension']['design_pictures'] as $pic) { 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']; $gallery[] = $pic['url'];
} }
} }
@@ -437,10 +592,12 @@ class MakerWorldService
} }
} }
// Extract print profile stats (first/default instance) // Extract print profile stats & instance information
$weightGrams = null; $weightGrams = null;
$printTimeHours = null; $printTimeHours = null;
$materials = ['pla', 'petg']; $materials = ['pla', 'petg'];
$printInfo = [];
$dimensions = null;
if (! empty($design['instances']) && is_array($design['instances'])) { if (! empty($design['instances']) && is_array($design['instances'])) {
$profile = $design['instances'][0]; $profile = $design['instances'][0];
@@ -453,6 +610,33 @@ class MakerWorldService
$seconds = (int) $profile['prediction']; $seconds = (int) $profile['prediction'];
$printTimeHours = round($seconds / 3600, 1); $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 // Calculate a smart suggested retail price
@@ -486,13 +670,16 @@ class MakerWorldService
'stock' => 15, 'stock' => 15,
'is_featured' => false, 'is_featured' => false,
'is_active' => true, 'is_active' => true,
'status' => 'published',
'dimensions' => $dimensions,
'weight_grams' => $weightGrams, 'weight_grams' => $weightGrams,
'print_time_hours' => $printTimeHours, 'print_time_hours' => $printTimeHours,
'print_info' => $printInfo,
'image_url' => $coverUrl, 'image_url' => $coverUrl,
'makerworld_url' => $makerworldUrl, 'makerworld_url' => $makerworldUrl,
'makerworld_author' => $authorName, 'makerworld_author' => $authorName,
'makerworld_license' => $license, 'makerworld_license' => $license,
'gallery_images' => array_slice($gallery, 0, 8), 'gallery_images' => $gallery,
'available_materials' => $materials, 'available_materials' => $materials,
'available_colors' => ['Mat Zwart', 'Sneeuwwit', 'Leisteen Grijs', 'Koningsblauw'], 'available_colors' => ['Mat Zwart', 'Sneeuwwit', 'Leisteen Grijs', 'Koningsblauw'],
]; ];
@@ -506,7 +693,9 @@ class MakerWorldService
?int $categoryId = null, ?int $categoryId = null,
?float $customPrice = null, ?float $customPrice = null,
?string $customName = null, ?string $customName = null,
bool $isActive = true bool $isActive = true,
string $status = 'published',
?int $collectionId = null
): Product { ): Product {
$formatted = $this->formatDesignForImport($designData); $formatted = $this->formatDesignForImport($designData);
@@ -524,6 +713,11 @@ class MakerWorldService
} }
$formatted['is_active'] = $isActive; $formatted['is_active'] = $isActive;
$formatted['status'] = $status;
if ($collectionId !== null) {
$formatted['makerworld_collection_id'] = $collectionId;
}
// Ensure slug is unique // Ensure slug is unique
$baseSlug = $formatted['slug']; $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. * Basic sanitization of MakerWorld HTML descriptions.
*/ */
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('makerworld_collections', function (Blueprint $table) {
$table->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');
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
$table->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',
]);
});
}
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,355 @@
@extends('layouts.admin')
@section('title', 'Artikel Beheer (Geïmporteerde Modellen) | Jouwdriedee.nl Beheer')
@section('content')
<div class="py-8 bg-slate-950 text-slate-100 min-h-screen" x-data="artikelBeheerApp()">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 space-y-6">
<!-- Top Header & Breadcrumb -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 pb-6 border-b border-slate-800">
<div>
<div class="flex items-center space-x-2 text-xs text-amber-400 font-bold uppercase tracking-wider mb-1">
<span class="inline-block w-2.5 h-2.5 rounded-full bg-amber-400 animate-pulse"></span>
<span>Staging & Curation</span>
<span>&bull;</span>
<span>Geïmporteerde Modellen</span>
</div>
<h1 class="text-2xl sm:text-3xl font-black text-white flex items-center space-x-3">
<span class="p-2 bg-gradient-to-tr from-amber-400 to-orange-500 rounded-2xl text-slate-950 shadow-lg shadow-amber-400/20">
<i data-lucide="layers" class="w-6 h-6"></i>
</span>
<span>Artikel Beheer</span>
</h1>
<p class="mt-1 text-sm text-slate-400">
Beoordeel automatisch binnengehaalde MakerWorld modellen. Stel de gewenste categorie en verkoopprijs in en publiceer ze met 1 klik naar de webshop!
</p>
</div>
<!-- Header Quick Actions -->
<div class="flex items-center space-x-3">
<a href="{{ route('admin.makerworld.index', ['tab' => 'collections']) }}" class="px-4 py-2.5 rounded-xl bg-slate-900 hover:bg-slate-800 text-slate-200 text-xs font-bold transition flex items-center space-x-2 border border-slate-800">
<i data-lucide="refresh-cw" class="w-4 h-4 text-amber-400"></i>
<span>Collecties & Auto-Sync</span>
</a>
<a href="{{ route('admin.products.index') }}" class="px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-white text-xs font-bold transition flex items-center space-x-2 border border-slate-700">
<i data-lucide="box" class="w-4 h-4"></i>
<span>Actieve Producten</span>
</a>
</div>
</div>
<!-- Notification Alerts -->
@if(session('success'))
<div class="p-4 rounded-2xl bg-emerald-950/60 border border-emerald-500/40 text-emerald-200 text-sm flex items-center space-x-3">
<i data-lucide="check-circle-2" class="w-5 h-5 text-emerald-400 shrink-0"></i>
<div class="font-bold">{{ session('success') }}</div>
</div>
@endif
@if(session('error'))
<div class="p-4 rounded-2xl bg-rose-950/60 border border-rose-500/40 text-rose-200 text-sm flex items-center space-x-3">
<i data-lucide="x-circle" class="w-5 h-5 text-rose-400 shrink-0"></i>
<div class="font-bold">{{ session('error') }}</div>
</div>
@endif
<!-- Filter & Search Toolbar -->
<div class="bg-slate-900 rounded-3xl p-4 sm:p-5 border border-slate-800 flex flex-col md:flex-row md:items-center justify-between gap-4">
<form method="GET" action="{{ route('admin.makerworld.staging') }}" class="flex flex-wrap items-center gap-3 flex-1">
<!-- Search input -->
<div class="relative min-w-[240px] flex-1">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-slate-500">
<i data-lucide="search" class="w-4 h-4"></i>
</div>
<input
type="text"
name="q"
value="{{ request('q') }}"
placeholder="Zoek op titel, maker of model-ID..."
class="w-full pl-9 pr-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white text-xs placeholder-slate-500 focus:ring-amber-400"
>
</div>
<!-- Collection dropdown filter -->
<div class="w-48">
<select name="collection_id" onchange="this.form.submit()" class="w-full px-3 py-2 bg-slate-950 border border-slate-800 rounded-xl text-white text-xs font-bold focus:ring-amber-400">
<option value="">Alle collecties</option>
@foreach($collections as $col)
<option value="{{ $col->id }}" {{ request('collection_id') == $col->id ? 'selected' : '' }}>
{{ $col->name }} ({{ $col->items_count }})
</option>
@endforeach
</select>
</div>
<button type="submit" class="px-3.5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-bold transition">
Filteren
</button>
@if(request()->hasAny(['q', 'collection_id']))
<a href="{{ route('admin.makerworld.staging') }}" class="text-xs text-rose-400 hover:underline font-bold">
Filters wissen
</a>
@endif
</form>
<div class="flex items-center space-x-3 text-xs">
<span class="px-3 py-1.5 rounded-xl bg-amber-400/10 text-amber-300 border border-amber-400/30 font-black">
{{ $totalStaged }} concepten wachten op beoordeling
</span>
</div>
</div>
<!-- Bulk Selection Action Bar -->
<div x-show="selectedIds.length > 0" x-cloak class="p-4 rounded-2xl bg-amber-400 text-slate-950 flex flex-col md:flex-row items-center justify-between gap-4 shadow-xl">
<div class="flex items-center space-x-2 font-black text-xs uppercase tracking-wider">
<i data-lucide="check-square" class="w-5 h-5"></i>
<span x-text="selectedIds.length + ' artikelen geselecteerd'"></span>
</div>
<form action="{{ route('admin.makerworld.staging.bulk-publish') }}" method="POST" class="flex flex-wrap items-center gap-3">
@csrf
<template x-for="id in selectedIds" :key="id">
<input type="hidden" name="product_ids[]" :value="id">
</template>
<div class="flex items-center space-x-2">
<label class="text-xs font-black uppercase">Categorie:</label>
<select name="category_id" required class="px-3 py-1.5 bg-slate-950 text-white rounded-xl text-xs font-bold">
@foreach($categories as $cat)
<option value="{{ $cat->id }}">{{ $cat->name }}</option>
@endforeach
</select>
</div>
<div class="flex items-center space-x-2">
<label class="text-xs font-black uppercase">Prijs-marge %:</label>
<input type="number" name="custom_markup" placeholder="0" class="w-16 px-2 py-1.5 bg-slate-950 text-white rounded-xl text-xs font-bold">
</div>
<button type="submit" class="px-4 py-2 bg-slate-950 text-amber-400 hover:bg-slate-900 font-black text-xs rounded-xl shadow-md transition flex items-center space-x-1.5">
<i data-lucide="check" class="w-4 h-4"></i>
<span>Publiceer Selectie naar Webshop</span>
</button>
</form>
<form action="{{ route('admin.makerworld.staging.bulk-reject') }}" method="POST" onsubmit="return confirm('Weet je zeker dat je de geselecteerde concepten wilt verwijderen?');">
@csrf
<template x-for="id in selectedIds" :key="id">
<input type="hidden" name="product_ids[]" :value="id">
</template>
<button type="submit" class="px-3 py-2 bg-rose-600 hover:bg-rose-700 text-white font-bold text-xs rounded-xl transition flex items-center space-x-1">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
<span>Verwijderen</span>
</button>
</form>
</div>
<!-- Select All Trigger -->
@if($stagedProducts->isNotEmpty())
<div class="flex items-center justify-between text-xs text-slate-400 px-2">
<button type="button" @click="toggleSelectAll()" class="hover:text-amber-400 font-bold transition flex items-center space-x-1.5">
<i data-lucide="check" class="w-4 h-4"></i>
<span x-text="selectedIds.length === {{ $stagedProducts->count() }} ? 'Deselecteer alles' : 'Selecteer alle {{ $stagedProducts->count() }} modellen op deze pagina'"></span>
</button>
<span>Pagina {{ $stagedProducts->currentPage() }} van {{ $stagedProducts->lastPage() }}</span>
</div>
@endif
<!-- Staged Products Grid -->
@if($stagedProducts->isEmpty())
<div class="text-center py-20 bg-slate-900/60 rounded-3xl border border-slate-800 p-8">
<div class="w-20 h-20 rounded-3xl bg-slate-800 flex items-center justify-center text-slate-400 mx-auto mb-4">
<i data-lucide="sparkles" class="w-10 h-10 text-amber-400"></i>
</div>
<h3 class="text-xl font-black text-white">Geen concepten in de wachtrij!</h3>
<p class="text-xs text-slate-400 max-w-md mx-auto mt-2">
Alle binnengehaalde modellen zijn al beoordeeld en gepubliceerd, of er zijn nog geen nieuwe modellen toegevoegd aan je gevolgde collecties.
</p>
<div class="mt-6 flex items-center justify-center space-x-3">
<a href="{{ route('admin.makerworld.index', ['tab' => 'collections']) }}" class="px-5 py-2.5 rounded-xl bg-amber-400 text-slate-950 font-black text-xs uppercase tracking-wider shadow-lg shadow-amber-400/20 transition">
Bekijk Collecties & Auto-Sync
</a>
</div>
</div>
@else
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach($stagedProducts as $product)
@php
$printInfo = $product->print_info ?? [];
@endphp
<div class="bg-slate-900 rounded-3xl border border-slate-800 overflow-hidden flex flex-col justify-between shadow-lg hover:border-slate-700 transition relative">
<!-- Top Image Preview & Gallery -->
<div>
<div class="relative aspect-4/3 bg-slate-950 overflow-hidden group">
<img src="{{ $product->image_url }}" alt="{{ $product->name }}" class="w-full h-full object-cover">
<!-- Selection Checkbox -->
<div class="absolute top-3 left-3 z-10">
<label class="flex items-center justify-center w-7 h-7 rounded-xl bg-slate-900/90 backdrop-blur border border-white/20 cursor-pointer shadow-md hover:scale-105 transition">
<input type="checkbox" value="{{ $product->id }}" x-model="selectedIds" class="w-4 h-4 text-amber-500 rounded focus:ring-0">
</label>
</div>
<!-- Draft Badge -->
<div class="absolute top-3 right-3 flex flex-col items-end gap-1.5">
<span class="px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-wider bg-amber-400 text-slate-950 shadow-md">
Concept
</span>
@if($product->makerworldCollection)
<span class="px-2 py-0.5 rounded-md text-[9px] font-bold bg-slate-950/80 text-amber-300 border border-amber-400/20 backdrop-blur truncate max-w-[150px]">
{{ $product->makerworldCollection->name }}
</span>
@endif
</div>
<!-- MakerWorld Link -->
<a href="https://makerworld.com/nl/models/{{ $product->makerworld_id }}" target="_blank" class="absolute bottom-3 right-3 p-2 rounded-xl bg-slate-950/80 hover:bg-slate-900 text-slate-300 hover:text-white border border-white/10 backdrop-blur transition" title="Bekijk origineel op MakerWorld">
<i data-lucide="external-link" class="w-3.5 h-3.5"></i>
</a>
</div>
<!-- Photo Thumbnails Strip (All Pulled Pictures) -->
@if(!empty($product->gallery_images) && count($product->gallery_images) > 0)
<div class="px-4 py-2 bg-slate-950 border-b border-slate-800/80 flex items-center space-x-2 overflow-x-auto">
<span class="text-[10px] font-bold text-slate-500 uppercase tracking-wider shrink-0">Foto's:</span>
<img src="{{ $product->image_url }}" alt="" class="w-9 h-9 rounded-lg object-cover border-2 border-amber-400 shrink-0">
@foreach(array_slice($product->gallery_images, 0, 6) as $galleryPic)
<img src="{{ $galleryPic }}" alt="" class="w-9 h-9 rounded-lg object-cover border border-slate-800 shrink-0 hover:border-slate-600 transition" loading="lazy">
@endforeach
@if(count($product->gallery_images) > 6)
<span class="text-[10px] text-slate-400 font-bold shrink-0">+{{ count($product->gallery_images) - 6 }} meer</span>
@endif
</div>
@endif
<!-- Print Specifications Strip -->
<div class="grid grid-cols-3 gap-1 px-4 py-2.5 bg-slate-950/40 border-b border-slate-800/60 text-center text-xs">
<div>
<span class="text-[10px] font-bold text-slate-500 block uppercase">Printtijd</span>
<span class="font-black text-slate-200">
{{ $product->print_time_hours ? $product->print_time_hours . ' uur' : '-' }}
</span>
</div>
<div>
<span class="text-[10px] font-bold text-slate-500 block uppercase">Gewicht</span>
<span class="font-black text-slate-200">
{{ $product->weight_grams ? $product->weight_grams . ' gram' : '-' }}
</span>
</div>
<div>
<span class="text-[10px] font-bold text-amber-400 block uppercase">Adviesprijs</span>
<span class="font-black text-amber-400">
{{ $product->formatted_price }}
</span>
</div>
</div>
<!-- Meta Details -->
<div class="p-4 space-y-2">
<div class="text-[11px] text-slate-400 flex items-center justify-between">
<span class="truncate">Maker: <strong class="text-slate-300">{{ $product->makerworld_author ?? 'MakerWorld Maker' }}</strong></span>
<span class="font-mono text-slate-500 text-[10px]">#{{ $product->makerworld_id }}</span>
</div>
</div>
</div>
<!-- Curation / Publish Form (Inline) -->
<form action="{{ route('admin.makerworld.staging.publish', $product) }}" method="POST" class="p-4 bg-slate-950 border-t border-slate-800 space-y-3">
@csrf
<!-- Editable Product Name -->
<div>
<label class="block text-[10px] font-black uppercase tracking-wider text-slate-400 mb-1">Producttitel</label>
<input
type="text"
name="name"
value="{{ old('name', $product->name) }}"
required
class="w-full px-3 py-1.5 bg-slate-900 border border-slate-700 rounded-xl text-white text-xs font-bold focus:ring-amber-400"
>
</div>
<div class="grid grid-cols-2 gap-2">
<!-- Category selector -->
<div>
<label class="block text-[10px] font-black uppercase tracking-wider text-slate-400 mb-1">Categorie</label>
<select name="category_id" required class="w-full px-2.5 py-1.5 bg-slate-900 border border-slate-700 rounded-xl text-white text-xs font-bold focus:ring-amber-400">
@foreach($categories as $cat)
<option value="{{ $cat->id }}" {{ $product->category_id == $cat->id ? 'selected' : '' }}>
{{ $cat->name }}
</option>
@endforeach
</select>
</div>
<!-- Retail Price -->
<div>
<label class="block text-[10px] font-black uppercase tracking-wider text-slate-400 mb-1">Verkoopprijs (€)</label>
<input
type="number"
step="0.01"
name="base_price"
value="{{ old('base_price', $product->base_price) }}"
required
class="w-full px-2.5 py-1.5 bg-slate-900 border border-slate-700 rounded-xl text-amber-300 text-xs font-black focus:ring-amber-400"
>
</div>
</div>
<!-- Action Buttons -->
<div class="pt-2 flex items-center justify-between gap-2">
<button type="submit" class="flex-1 py-2 px-3 bg-emerald-500 hover:bg-emerald-400 text-slate-950 font-black rounded-xl text-xs uppercase tracking-wider shadow-md shadow-emerald-500/20 transition flex items-center justify-center space-x-1">
<i data-lucide="check" class="w-4 h-4"></i>
<span>Publiceer naar Webshop</span>
</button>
<button
type="submit"
form="reject-form-{{ $product->id }}"
class="p-2 rounded-xl text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 border border-slate-800 transition"
title="Concept afwijzen & verwijderen"
>
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</div>
</form>
<!-- Standalone Delete Form for this product -->
<form id="reject-form-{{ $product->id }}" action="{{ route('admin.makerworld.staging.reject', $product) }}" method="POST" onsubmit="return confirm('Weet je zeker dat je dit concept wilt verwijderen?');" class="hidden">
@csrf
@method('DELETE')
</form>
</div>
@endforeach
</div>
<!-- Pagination -->
<div class="mt-8">
{{ $stagedProducts->links() }}
</div>
@endif
</div>
</div>
<script>
function artikelBeheerApp() {
return {
selectedIds: [],
allIds: @json($stagedProducts->pluck('id')->toArray()),
toggleSelectAll() {
if (this.selectedIds.length === this.allIds.length) {
this.selectedIds = [];
} else {
this.selectedIds = [...this.allIds];
}
}
}
}
</script>
@endsection
+23 -2
View File
@@ -44,6 +44,19 @@
<span>Producten</span> <span>Producten</span>
</a> </a>
@php
$draftBadgeCount = \App\Models\Product::where('status', 'draft')->count();
@endphp
<a href="{{ route('admin.makerworld.staging') }}" class="px-3 py-2 rounded-xl transition flex items-center space-x-1.5 {{ request()->routeIs('admin.makerworld.staging*') ? 'bg-amber-400 text-slate-950 font-black' : 'text-slate-300 hover:text-white hover:bg-slate-800' }}">
<i data-lucide="layers" class="w-4 h-4"></i>
<span>Artikel Beheer</span>
@if($draftBadgeCount > 0)
<span class="ml-1 px-1.5 py-0.2 rounded-full text-[10px] font-black {{ request()->routeIs('admin.makerworld.staging*') ? 'bg-slate-950 text-amber-400' : 'bg-amber-400 text-slate-950' }}">
{{ $draftBadgeCount }}
</span>
@endif
</a>
<a href="{{ route('admin.categories.index') }}" class="px-3 py-2 rounded-xl transition flex items-center space-x-1.5 {{ request()->routeIs('admin.categories.*') ? 'bg-amber-400 text-slate-950 font-black' : 'text-slate-300 hover:text-white hover:bg-slate-800' }}"> <a href="{{ route('admin.categories.index') }}" class="px-3 py-2 rounded-xl transition flex items-center space-x-1.5 {{ request()->routeIs('admin.categories.*') ? 'bg-amber-400 text-slate-950 font-black' : 'text-slate-300 hover:text-white hover:bg-slate-800' }}">
<i data-lucide="folder-tree" class="w-4 h-4"></i> <i data-lucide="folder-tree" class="w-4 h-4"></i>
<span>Categorieën</span> <span>Categorieën</span>
@@ -54,9 +67,9 @@
<span>Bestellingen</span> <span>Bestellingen</span>
</a> </a>
<a href="{{ route('admin.makerworld.index') }}" class="px-3 py-2 rounded-xl transition flex items-center space-x-1.5 {{ request()->routeIs('admin.makerworld.*') ? 'bg-amber-400 text-slate-950 font-black' : 'text-slate-300 hover:text-white hover:bg-slate-800' }}"> <a href="{{ route('admin.makerworld.index') }}" class="px-3 py-2 rounded-xl transition flex items-center space-x-1.5 {{ request()->routeIs('admin.makerworld.index*') ? 'bg-amber-400 text-slate-950 font-black' : 'text-slate-300 hover:text-white hover:bg-slate-800' }}">
<i data-lucide="download-cloud" class="w-4 h-4"></i> <i data-lucide="download-cloud" class="w-4 h-4"></i>
<span>MakerWorld</span> <span>MakerWorld Sync</span>
</a> </a>
</nav> </nav>
</div> </div>
@@ -85,6 +98,14 @@
<a href="{{ route('admin.products.index') }}" class="block px-3 py-2 rounded-xl {{ request()->routeIs('admin.products.*') ? 'bg-amber-400 text-slate-950' : 'text-slate-300' }}"> <a href="{{ route('admin.products.index') }}" class="block px-3 py-2 rounded-xl {{ request()->routeIs('admin.products.*') ? 'bg-amber-400 text-slate-950' : 'text-slate-300' }}">
📦 Producten 📦 Producten
</a> </a>
<a href="{{ route('admin.makerworld.staging') }}" class="block px-3 py-2 rounded-xl {{ request()->routeIs('admin.makerworld.staging*') ? 'bg-amber-400 text-slate-950' : 'text-slate-300' }}">
⚡ Artikel Beheer (Geïmporteerd)
@if(($draftBadgeCount ?? 0) > 0)
<span class="ml-2 px-1.5 py-0.5 rounded-full text-xs font-black bg-amber-400 text-slate-950">
{{ $draftBadgeCount }} nieuw
</span>
@endif
</a>
<a href="{{ route('admin.categories.index') }}" class="block px-3 py-2 rounded-xl {{ request()->routeIs('admin.categories.*') ? 'bg-amber-400 text-slate-950' : 'text-slate-300' }}"> <a href="{{ route('admin.categories.index') }}" class="block px-3 py-2 rounded-xl {{ request()->routeIs('admin.categories.*') ? 'bg-amber-400 text-slate-950' : 'text-slate-300' }}">
📁 Categorieën 📁 Categorieën
</a> </a>
+7
View File
@@ -2,7 +2,14 @@
use Illuminate\Foundation\Inspiring; use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
Artisan::command('inspire', function () { Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
// Automatische MakerWorld Collecties Synchronisatie iedere 10 minuten
Schedule::command('app:sync-makerworld-collections')
->everyTenMinutes()
->withoutOverlapping()
->runInBackground();
+14 -1
View File
@@ -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::get('/orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
Route::post('/orders/{order}/status', [AdminOrderController::class, 'updateStatus'])->name('orders.status'); 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::get('/makerworld', [MakerWorldController::class, 'index'])->name('makerworld.index');
Route::post('/makerworld/login', [MakerWorldController::class, 'login'])->name('makerworld.login'); Route::post('/makerworld/login', [MakerWorldController::class, 'login'])->name('makerworld.login');
Route::post('/makerworld/verify-code', [MakerWorldController::class, 'verifyCode'])->name('makerworld.verify-code'); 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::get('/makerworld/preview', [MakerWorldController::class, 'preview'])->name('makerworld.preview');
Route::post('/makerworld/import', [MakerWorldController::class, 'import'])->name('makerworld.import'); Route::post('/makerworld/import', [MakerWorldController::class, 'import'])->name('makerworld.import');
Route::post('/makerworld/batch-import', [MakerWorldController::class, 'batchImport'])->name('makerworld.batch-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');
}); });
-1
View File
@@ -216,4 +216,3 @@ class AuthAndEmailTest extends TestCase
}); });
} }
} }
@@ -0,0 +1,290 @@
<?php
namespace Tests\Feature;
use App\Models\Category;
use App\Models\MakerWorldCollection;
use App\Models\Product;
use App\Models\User;
use App\Services\MakerWorldService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class MakerWorldCollectionSyncTest extends TestCase
{
use RefreshDatabase;
protected User $admin;
protected Category $category;
protected function setUp(): void
{
parent::setUp();
$this->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' => '<p>Super flexibele fidget slug.</p>',
'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' => '<p>Super flexibele fidget slug.</p>',
'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]);
}
}