Implement MakerWorld collections sync every 10 minutes and Artikel Beheer staging workflow
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\MakerWorldCollection;
|
||||
use App\Models\Product;
|
||||
use App\Services\MakerWorldService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -18,17 +19,22 @@ class MakerWorldController extends Controller
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Display the MakerWorld importer dashboard.
|
||||
* Display the MakerWorld importer & collections dashboard.
|
||||
*/
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$isAuthenticated = $this->makerWorld->isAuthenticated();
|
||||
$userProfile = $this->makerWorld->getUserProfile();
|
||||
|
||||
$tab = $request->query('tab', $isAuthenticated ? 'favorites' : 'community');
|
||||
$tab = $request->query('tab', 'collections');
|
||||
$models = [];
|
||||
$apiCollections = [];
|
||||
|
||||
if ($tab === 'favorites' && $isAuthenticated) {
|
||||
if ($tab === 'collections') {
|
||||
if ($isAuthenticated) {
|
||||
$apiCollections = $this->makerWorld->getFavoritesCollections();
|
||||
}
|
||||
} elseif ($tab === 'favorites' && $isAuthenticated) {
|
||||
$models = $this->makerWorld->getFavorites();
|
||||
} elseif ($tab === 'published' && $isAuthenticated) {
|
||||
$models = $this->makerWorld->getPublished();
|
||||
@@ -36,15 +42,11 @@ class MakerWorldController extends Controller
|
||||
$models = $this->makerWorld->getCommunityShowcase();
|
||||
}
|
||||
|
||||
$importedProducts = Product::with('category')
|
||||
->whereNotNull('makerworld_id')
|
||||
$trackedCollections = MakerWorldCollection::withCount(['products', 'draftProducts', 'publishedProducts'])
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
$importedIds = Product::whereNotNull('makerworld_id')
|
||||
->pluck('makerworld_id')
|
||||
->toArray();
|
||||
->get();
|
||||
|
||||
$stagedCount = Product::where('status', 'draft')->count();
|
||||
$categories = Category::orderBy('name')->get();
|
||||
|
||||
return view('admin.makerworld.index', [
|
||||
@@ -52,12 +54,266 @@ class MakerWorldController extends Controller
|
||||
'userProfile' => $userProfile,
|
||||
'currentTab' => $tab,
|
||||
'models' => $models,
|
||||
'importedProducts' => $importedProducts,
|
||||
'importedIds' => $importedIds,
|
||||
'apiCollections' => $apiCollections,
|
||||
'trackedCollections' => $trackedCollections,
|
||||
'stagedCount' => $stagedCount,
|
||||
'categories' => $categories,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the "Artikel Beheer" staging area where imported models wait for review.
|
||||
*/
|
||||
public function staging(Request $request): View
|
||||
{
|
||||
$query = Product::with(['category', 'makerworldCollection'])
|
||||
->where('status', 'draft');
|
||||
|
||||
if ($request->filled('collection_id')) {
|
||||
$query->where('makerworld_collection_id', $request->input('collection_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('q')) {
|
||||
$q = $request->input('q');
|
||||
$query->where(function ($sub) use ($q) {
|
||||
$sub->where('name', 'like', "%{$q}%")
|
||||
->orWhere('makerworld_author', 'like', "%{$q}%")
|
||||
->orWhere('makerworld_id', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
$stagedProducts = $query->latest()->paginate(24)->withQueryString();
|
||||
$categories = Category::orderBy('name')->get();
|
||||
$collections = MakerWorldCollection::all();
|
||||
$totalStaged = Product::where('status', 'draft')->count();
|
||||
|
||||
return view('admin.makerworld.staging', compact('stagedProducts', 'categories', 'collections', 'totalStaged'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a MakerWorld collection to be tracked and auto-synced.
|
||||
*/
|
||||
public function addCollection(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'collection_input' => 'required|string',
|
||||
'is_active_sync' => 'nullable|boolean',
|
||||
'sync_now' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$input = trim($validated['collection_input']);
|
||||
$parsed = $this->makerWorld->parseCollectionIdentifier($input);
|
||||
|
||||
$collectionId = $parsed['id'];
|
||||
$userHandle = $parsed['handle'] ?? 'user_1718037659';
|
||||
|
||||
// If user entered a user collections URL or handle without specific collection ID
|
||||
if ($parsed['type'] === 'user_collections' && ! $collectionId) {
|
||||
// Attempt to fetch collection list for user
|
||||
$userCollections = $this->makerWorld->getFavoritesCollections();
|
||||
|
||||
if (! empty($userCollections)) {
|
||||
$addedCount = 0;
|
||||
foreach ($userCollections as $c) {
|
||||
$cId = (string) ($c['id'] ?? '');
|
||||
if ($cId !== '') {
|
||||
$collection = MakerWorldCollection::firstOrCreate(
|
||||
['makerworld_collection_id' => $cId],
|
||||
[
|
||||
'name' => $c['title'] ?? 'MakerWorld Collectie #'.$cId,
|
||||
'description' => $c['description'] ?? null,
|
||||
'cover_url' => is_array($c['designCover'] ?? null) ? ($c['designCover'][0] ?? null) : ($c['cover'] ?? null),
|
||||
'user_handle' => $userHandle,
|
||||
'source_url' => $input,
|
||||
'is_active_sync' => true,
|
||||
'sync_interval_minutes' => 10,
|
||||
]
|
||||
);
|
||||
$this->makerWorld->syncCollection($collection);
|
||||
$addedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('admin.makerworld.index', ['tab' => 'collections'])
|
||||
->with('success', "{$addedCount} collecties van @{$userHandle} succesvol gekoppeld en gesynchroniseerd!");
|
||||
}
|
||||
|
||||
return redirect()->route('admin.makerworld.index', ['tab' => 'collections'])
|
||||
->with('warning', "Gebruikersprofiel @{$userHandle} herkend. Voer a.u.b. een specifieke collectie-link of ID in (bijv. https://makerworld.com/nl/collections/123456) of log in bij MakerWorld.");
|
||||
}
|
||||
|
||||
if (! $collectionId) {
|
||||
return redirect()->route('admin.makerworld.index', ['tab' => 'collections'])
|
||||
->with('error', 'Kon geen geldige MakerWorld collectie ID of URL herkennen.');
|
||||
}
|
||||
|
||||
// Fetch collection details from MakerWorld
|
||||
$details = $this->makerWorld->getCollectionDetails($collectionId);
|
||||
$name = $details['title'] ?? 'MakerWorld Collectie #'.$collectionId;
|
||||
$cover = is_array($details['designCover'] ?? null) ? ($details['designCover'][0] ?? null) : ($details['cover'] ?? null);
|
||||
$desc = $details['description'] ?? null;
|
||||
|
||||
$collection = MakerWorldCollection::updateOrCreate(
|
||||
['makerworld_collection_id' => (string) $collectionId],
|
||||
[
|
||||
'name' => $name,
|
||||
'description' => $desc,
|
||||
'cover_url' => $cover,
|
||||
'user_handle' => $userHandle,
|
||||
'source_url' => $input,
|
||||
'is_active_sync' => $request->boolean('is_active_sync', true),
|
||||
'sync_interval_minutes' => 10,
|
||||
'raw_metadata' => $details,
|
||||
]
|
||||
);
|
||||
|
||||
$msg = "Collectie '{$collection->name}' is succesvol toegevoegd!";
|
||||
|
||||
if ($request->boolean('sync_now', true)) {
|
||||
$syncResult = $this->makerWorld->syncCollection($collection);
|
||||
$msg .= " Direct {$syncResult['imported']} modellen binnengehaald als concept voor Artikel Beheer.";
|
||||
}
|
||||
|
||||
return redirect()->route('admin.makerworld.index', ['tab' => 'collections'])
|
||||
->with('success', $msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle active 10-minute sync for a collection.
|
||||
*/
|
||||
public function toggleCollectionSync(MakerWorldCollection $collection): RedirectResponse
|
||||
{
|
||||
$collection->update([
|
||||
'is_active_sync' => ! $collection->is_active_sync,
|
||||
]);
|
||||
|
||||
$statusStr = $collection->is_active_sync ? 'geactiveerd (elke 10 min)' : 'gepauzeerd';
|
||||
|
||||
return redirect()->back()
|
||||
->with('success', "Automatische synchronisatie voor '{$collection->name}' is {$statusStr}.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger immediate synchronization for a collection.
|
||||
*/
|
||||
public function syncCollectionNow(MakerWorldCollection $collection): RedirectResponse
|
||||
{
|
||||
$result = $this->makerWorld->syncCollection($collection);
|
||||
|
||||
if ($result['error']) {
|
||||
return redirect()->back()
|
||||
->with('error', "Synchronisatie mislukt: {$result['error']}");
|
||||
}
|
||||
|
||||
return redirect()->back()
|
||||
->with('success', "Collectie '{$collection->name}' gesynchroniseerd: {$result['imported']} nieuwe modellen binnengehaald, {$result['updated']} bijgewerkt.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tracked collection.
|
||||
*/
|
||||
public function deleteCollection(MakerWorldCollection $collection): RedirectResponse
|
||||
{
|
||||
$name = $collection->name;
|
||||
$collection->delete();
|
||||
|
||||
return redirect()->route('admin.makerworld.index', ['tab' => 'collections'])
|
||||
->with('success', "Collectie '{$name}' is verwijderd uit de synchronisatie.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a single staged model to the active webshop catalog.
|
||||
*/
|
||||
public function publishStaged(Request $request, Product $product): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'base_price' => 'required|numeric|min:0.5',
|
||||
'short_description' => 'nullable|string|max:500',
|
||||
'is_featured' => 'nullable|boolean',
|
||||
]);
|
||||
|
||||
$product->update([
|
||||
'name' => $validated['name'],
|
||||
'category_id' => $validated['category_id'],
|
||||
'base_price' => $validated['base_price'],
|
||||
'short_description' => $validated['short_description'] ?? $product->short_description,
|
||||
'is_featured' => $request->boolean('is_featured', false),
|
||||
'status' => 'published',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return redirect()->back()
|
||||
->with('success', "Artikel '{$product->name}' is succesvol goedgekeurd en gepubliceerd in de webshop!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk publish selected staged models.
|
||||
*/
|
||||
public function bulkPublishStaged(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'product_ids' => 'required|array',
|
||||
'product_ids.*' => 'integer|exists:products,id',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'custom_markup' => 'nullable|numeric',
|
||||
]);
|
||||
|
||||
$products = Product::whereIn('id', $validated['product_ids'])
|
||||
->where('status', 'draft')
|
||||
->get();
|
||||
|
||||
$count = 0;
|
||||
foreach ($products as $p) {
|
||||
$price = $p->base_price;
|
||||
if (! empty($validated['custom_markup']) && $validated['custom_markup'] > 0) {
|
||||
$price = round($price * (1 + ($validated['custom_markup'] / 100)), 2);
|
||||
}
|
||||
|
||||
$p->update([
|
||||
'category_id' => $validated['category_id'],
|
||||
'base_price' => $price,
|
||||
'status' => 'published',
|
||||
'is_active' => true,
|
||||
]);
|
||||
$count++;
|
||||
}
|
||||
|
||||
return redirect()->back()
|
||||
->with('success', "{$count} artikelen succesvol gepubliceerd in de webshop!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject and delete a staged model.
|
||||
*/
|
||||
public function rejectStaged(Product $product): RedirectResponse
|
||||
{
|
||||
$name = $product->name;
|
||||
$product->delete();
|
||||
|
||||
return redirect()->back()
|
||||
->with('success', "Concept '{$name}' is verwijderd.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk reject staged models.
|
||||
*/
|
||||
public function bulkRejectStaged(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'product_ids' => 'required|array',
|
||||
'product_ids.*' => 'integer|exists:products,id',
|
||||
]);
|
||||
|
||||
$count = Product::whereIn('id', $validated['product_ids'])
|
||||
->where('status', 'draft')
|
||||
->delete();
|
||||
|
||||
return redirect()->back()
|
||||
->with('success', "{$count} concepten zijn verwijderd.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with MakerWorld credentials.
|
||||
*/
|
||||
@@ -75,7 +331,7 @@ class MakerWorldController extends Controller
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
return redirect()
|
||||
->route('admin.makerworld.index', ['tab' => 'favorites'])
|
||||
->route('admin.makerworld.index', ['tab' => 'collections'])
|
||||
->with('success', $result['message']);
|
||||
}
|
||||
|
||||
@@ -108,7 +364,7 @@ class MakerWorldController extends Controller
|
||||
|
||||
if ($result['status'] === 'success') {
|
||||
return redirect()
|
||||
->route('admin.makerworld.index', ['tab' => 'favorites'])
|
||||
->route('admin.makerworld.index', ['tab' => 'collections'])
|
||||
->with('success', $result['message']);
|
||||
}
|
||||
|
||||
@@ -130,7 +386,7 @@ class MakerWorldController extends Controller
|
||||
$this->makerWorld->setAccessToken($validated['token']);
|
||||
|
||||
return redirect()
|
||||
->route('admin.makerworld.index', ['tab' => 'favorites'])
|
||||
->route('admin.makerworld.index', ['tab' => 'collections'])
|
||||
->with('success', 'MakerWorld token succesvol opgeslagen en verbonden!');
|
||||
}
|
||||
|
||||
@@ -142,7 +398,7 @@ class MakerWorldController extends Controller
|
||||
$this->makerWorld->logout();
|
||||
|
||||
return redirect()
|
||||
->route('admin.makerworld.index', ['tab' => 'community'])
|
||||
->route('admin.makerworld.index', ['tab' => 'collections'])
|
||||
->with('success', 'MakerWorld account succesvol ontkoppeld.');
|
||||
}
|
||||
|
||||
@@ -191,6 +447,7 @@ class MakerWorldController extends Controller
|
||||
'custom_price' => 'nullable|numeric|min:0.5',
|
||||
'custom_name' => 'nullable|string|max:255',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'status' => 'nullable|string|in:draft,published',
|
||||
]);
|
||||
|
||||
$details = $this->makerWorld->getDesignDetails($validated['makerworld_id']);
|
||||
@@ -204,15 +461,18 @@ class MakerWorldController extends Controller
|
||||
return redirect()->back()->with('error', $msg);
|
||||
}
|
||||
|
||||
$status = $validated['status'] ?? ($request->boolean('is_active', true) ? 'published' : 'draft');
|
||||
|
||||
$product = $this->makerWorld->importDesign(
|
||||
$details,
|
||||
$validated['category_id'] ?? null,
|
||||
$validated['custom_price'] ?? null,
|
||||
$validated['custom_name'] ?? null,
|
||||
$request->boolean('is_active', true)
|
||||
$status === 'published',
|
||||
$status
|
||||
);
|
||||
|
||||
$msg = "Model \"{$product->name}\" is succesvol toegevoegd aan je webshop!";
|
||||
$msg = "Model \"{$product->name}\" is succesvol toegevoegd!";
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
@@ -249,12 +509,13 @@ class MakerWorldController extends Controller
|
||||
$categoryId,
|
||||
null,
|
||||
null,
|
||||
$isActive
|
||||
$isActive,
|
||||
$isActive ? 'published' : 'draft'
|
||||
);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', "{$count} modellen succesvol geïmporteerd naar je webshop!");
|
||||
return redirect()->back()->with('success', "{$count} modellen succesvol geïmporteerd!");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,4 +122,3 @@ class ForgotPasswordController extends Controller
|
||||
->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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ class ShopController extends Controller
|
||||
->get();
|
||||
|
||||
$featuredProducts = Product::with('category')
|
||||
->where('is_active', true)
|
||||
->published()
|
||||
->where('is_featured', true)
|
||||
->latest()
|
||||
->take(6)
|
||||
@@ -32,7 +32,7 @@ class ShopController extends Controller
|
||||
|
||||
public function catalog(Request $request)
|
||||
{
|
||||
$query = Product::with('category')->where('is_active', true);
|
||||
$query = Product::with('category')->published();
|
||||
|
||||
// Filter op categorie
|
||||
if ($request->filled('category')) {
|
||||
@@ -75,13 +75,13 @@ class ShopController extends Controller
|
||||
|
||||
public function show($slug)
|
||||
{
|
||||
$product = Product::with('category')->where('slug', $slug)->firstOrFail();
|
||||
$product = Product::with('category')->published()->where('slug', $slug)->firstOrFail();
|
||||
$allMaterials = Material::where('is_active', true)->get()->keyBy('code');
|
||||
$allColors = Color::where('in_stock', true)->get();
|
||||
|
||||
$relatedProducts = Product::where('id', '!=', $product->id)
|
||||
$relatedProducts = Product::published()
|
||||
->where('id', '!=', $product->id)
|
||||
->where('category_id', $product->category_id)
|
||||
->where('is_active', true)
|
||||
->take(3)
|
||||
->get();
|
||||
|
||||
|
||||
@@ -26,4 +26,3 @@ class EnsureUserIsAdmin
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -13,6 +14,7 @@ class Product extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'makerworld_id',
|
||||
'makerworld_collection_id',
|
||||
'category_id',
|
||||
'name',
|
||||
'slug',
|
||||
@@ -24,9 +26,11 @@ class Product extends Model
|
||||
'stock',
|
||||
'is_featured',
|
||||
'is_active',
|
||||
'status',
|
||||
'dimensions',
|
||||
'weight_grams',
|
||||
'print_time_hours',
|
||||
'print_info',
|
||||
'image_url',
|
||||
'makerworld_url',
|
||||
'makerworld_author',
|
||||
@@ -43,6 +47,7 @@ class Product extends Model
|
||||
'is_featured' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'print_time_hours' => 'float',
|
||||
'print_info' => 'array',
|
||||
'gallery_images' => 'array',
|
||||
'available_materials' => 'array',
|
||||
'available_colors' => 'array',
|
||||
@@ -53,11 +58,30 @@ class Product extends Model
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function makerworldCollection(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(MakerWorldCollection::class, 'makerworld_collection_id');
|
||||
}
|
||||
|
||||
public function orderItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function scopePublished(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true)
|
||||
->where(function ($q) {
|
||||
$q->where('status', 'published')
|
||||
->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeDraft(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', 'draft');
|
||||
}
|
||||
|
||||
public function getFormattedPriceAttribute(): string
|
||||
{
|
||||
return '€ '.number_format((float) ($this->base_price ?? 0), 2, ',', '.');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\MakerWorldCollection;
|
||||
use App\Models\Product;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
@@ -404,6 +405,160 @@ class MakerWorldService
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a MakerWorld collection URL, handle, or ID.
|
||||
* Examples:
|
||||
* - https://makerworld.com/nl/@user_1718037659/collections -> type: user_collections, handle: user_1718037659
|
||||
* - https://makerworld.com/nl/collections/9205051 -> type: collection, id: 9205051
|
||||
* - 9205051 -> type: collection, id: 9205051
|
||||
*
|
||||
* @return array{type: string, id: ?int, handle: ?string, raw: string}
|
||||
*/
|
||||
public function parseCollectionIdentifier(string $input): array
|
||||
{
|
||||
$trimmed = trim($input);
|
||||
|
||||
// Direct numeric ID
|
||||
if (is_numeric($trimmed)) {
|
||||
return [
|
||||
'type' => 'collection',
|
||||
'id' => (int) $trimmed,
|
||||
'handle' => null,
|
||||
'raw' => $trimmed,
|
||||
];
|
||||
}
|
||||
|
||||
// Collection URL: /collections/12345
|
||||
if (preg_match('/collections\/(\d+)/i', $trimmed, $matches)) {
|
||||
return [
|
||||
'type' => 'collection',
|
||||
'id' => (int) $matches[1],
|
||||
'handle' => null,
|
||||
'raw' => $trimmed,
|
||||
];
|
||||
}
|
||||
|
||||
// User collections URL: /@user_1718037659/collections
|
||||
if (preg_match('/@([a-zA-Z0-9_\-]+)\/collections/i', $trimmed, $matches)) {
|
||||
return [
|
||||
'type' => 'user_collections',
|
||||
'id' => null,
|
||||
'handle' => $matches[1],
|
||||
'raw' => $trimmed,
|
||||
];
|
||||
}
|
||||
|
||||
// Just handle: @user_1718037659
|
||||
if (str_starts_with($trimmed, '@')) {
|
||||
return [
|
||||
'type' => 'user_collections',
|
||||
'id' => null,
|
||||
'handle' => ltrim($trimmed, '@'),
|
||||
'raw' => $trimmed,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => 'unknown',
|
||||
'id' => null,
|
||||
'handle' => null,
|
||||
'raw' => $trimmed,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user's favorite collections from MakerWorld.
|
||||
*
|
||||
* @return array<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.
|
||||
*/
|
||||
@@ -423,7 +578,7 @@ class MakerWorldService
|
||||
|
||||
if (! empty($design['designExtension']['design_pictures']) && is_array($design['designExtension']['design_pictures'])) {
|
||||
foreach ($design['designExtension']['design_pictures'] as $pic) {
|
||||
if (! empty($pic['url']) && $pic['url'] !== $coverUrl) {
|
||||
if (! empty($pic['url']) && $pic['url'] !== $coverUrl && ! in_array($pic['url'], $gallery, true)) {
|
||||
$gallery[] = $pic['url'];
|
||||
}
|
||||
}
|
||||
@@ -437,10 +592,12 @@ class MakerWorldService
|
||||
}
|
||||
}
|
||||
|
||||
// Extract print profile stats (first/default instance)
|
||||
// Extract print profile stats & instance information
|
||||
$weightGrams = null;
|
||||
$printTimeHours = null;
|
||||
$materials = ['pla', 'petg'];
|
||||
$printInfo = [];
|
||||
$dimensions = null;
|
||||
|
||||
if (! empty($design['instances']) && is_array($design['instances'])) {
|
||||
$profile = $design['instances'][0];
|
||||
@@ -453,6 +610,33 @@ class MakerWorldService
|
||||
$seconds = (int) $profile['prediction'];
|
||||
$printTimeHours = round($seconds / 3600, 1);
|
||||
}
|
||||
|
||||
// Extract instance photos if available
|
||||
if (! empty($profile['pictures']) && is_array($profile['pictures'])) {
|
||||
foreach ($profile['pictures'] as $p) {
|
||||
$url = is_array($p) ? ($p['url'] ?? null) : $p;
|
||||
if ($url && $url !== $coverUrl && ! in_array($url, $gallery, true)) {
|
||||
$gallery[] = $url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Structured print info
|
||||
$printInfo = [
|
||||
'profile_title' => $profile['title'] ?? null,
|
||||
'printer' => $profile['printerName'] ?? $profile['modelName'] ?? 'Bambu Lab Printer',
|
||||
'nozzle' => $profile['nozzleDiameter'] ?? '0.4 mm',
|
||||
'weight_grams' => $weightGrams,
|
||||
'print_time_hours' => $printTimeHours,
|
||||
'prediction_seconds' => $profile['prediction'] ?? null,
|
||||
'plate_count' => count($profile['plates'] ?? []) ?: 1,
|
||||
'filaments' => $profile['filamentConfig'] ?? $profile['filaments'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
// Dimensions if specified
|
||||
if (! empty($design['dimensions'])) {
|
||||
$dimensions = is_array($design['dimensions']) ? implode(' x ', $design['dimensions']) : (string) $design['dimensions'];
|
||||
}
|
||||
|
||||
// Calculate a smart suggested retail price
|
||||
@@ -486,13 +670,16 @@ class MakerWorldService
|
||||
'stock' => 15,
|
||||
'is_featured' => false,
|
||||
'is_active' => true,
|
||||
'status' => 'published',
|
||||
'dimensions' => $dimensions,
|
||||
'weight_grams' => $weightGrams,
|
||||
'print_time_hours' => $printTimeHours,
|
||||
'print_info' => $printInfo,
|
||||
'image_url' => $coverUrl,
|
||||
'makerworld_url' => $makerworldUrl,
|
||||
'makerworld_author' => $authorName,
|
||||
'makerworld_license' => $license,
|
||||
'gallery_images' => array_slice($gallery, 0, 8),
|
||||
'gallery_images' => $gallery,
|
||||
'available_materials' => $materials,
|
||||
'available_colors' => ['Mat Zwart', 'Sneeuwwit', 'Leisteen Grijs', 'Koningsblauw'],
|
||||
];
|
||||
@@ -506,7 +693,9 @@ class MakerWorldService
|
||||
?int $categoryId = null,
|
||||
?float $customPrice = null,
|
||||
?string $customName = null,
|
||||
bool $isActive = true
|
||||
bool $isActive = true,
|
||||
string $status = 'published',
|
||||
?int $collectionId = null
|
||||
): Product {
|
||||
$formatted = $this->formatDesignForImport($designData);
|
||||
|
||||
@@ -524,6 +713,11 @@ class MakerWorldService
|
||||
}
|
||||
|
||||
$formatted['is_active'] = $isActive;
|
||||
$formatted['status'] = $status;
|
||||
|
||||
if ($collectionId !== null) {
|
||||
$formatted['makerworld_collection_id'] = $collectionId;
|
||||
}
|
||||
|
||||
// Ensure slug is unique
|
||||
$baseSlug = $formatted['slug'];
|
||||
@@ -539,6 +733,99 @@ class MakerWorldService
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize designs from a MakerWorld collection.
|
||||
* New items are staged in 'draft' status (is_active = false) for admin curation.
|
||||
*
|
||||
* @return array{imported: int, updated: int, total: int, error: ?string}
|
||||
*/
|
||||
public function syncCollection(MakerWorldCollection $collection): array
|
||||
{
|
||||
$collection->update([
|
||||
'last_sync_status' => 'running',
|
||||
'last_sync_message' => 'Synchronisatie bezig...',
|
||||
]);
|
||||
|
||||
try {
|
||||
$designs = $this->getCollectionDesigns($collection->makerworld_collection_id);
|
||||
|
||||
$imported = 0;
|
||||
$updated = 0;
|
||||
|
||||
foreach ($designs as $design) {
|
||||
$designId = (int) ($design['id'] ?? 0);
|
||||
if ($designId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingProduct = Product::where('makerworld_id', $designId)->first();
|
||||
|
||||
if ($existingProduct) {
|
||||
// Associate with collection if not yet linked
|
||||
$updates = [];
|
||||
if (! $existingProduct->makerworld_collection_id) {
|
||||
$updates['makerworld_collection_id'] = $collection->id;
|
||||
}
|
||||
if (empty($existingProduct->gallery_images) && ! empty($design['designExtension']['design_pictures'])) {
|
||||
$formatted = $this->formatDesignForImport($design);
|
||||
$updates['gallery_images'] = $formatted['gallery_images'];
|
||||
}
|
||||
if (empty($existingProduct->print_info)) {
|
||||
$formatted = $this->formatDesignForImport($design);
|
||||
$updates['print_info'] = $formatted['print_info'];
|
||||
}
|
||||
if (! empty($updates)) {
|
||||
$existingProduct->update($updates);
|
||||
}
|
||||
$updated++;
|
||||
} else {
|
||||
// Fetch full details if necessary to get all high-res photos and instances
|
||||
$fullDetails = $this->getDesignDetails($designId) ?? $design;
|
||||
|
||||
$this->importDesign(
|
||||
designData: $fullDetails,
|
||||
categoryId: null,
|
||||
customPrice: null,
|
||||
customName: null,
|
||||
isActive: false,
|
||||
status: 'draft',
|
||||
collectionId: $collection->id
|
||||
);
|
||||
$imported++;
|
||||
}
|
||||
}
|
||||
|
||||
$collection->update([
|
||||
'items_count' => count($designs),
|
||||
'last_synced_at' => now(),
|
||||
'last_sync_status' => 'success',
|
||||
'last_sync_message' => "{$imported} nieuwe modellen geïmporteerd als concept, {$updated} bijgewerkt.",
|
||||
]);
|
||||
|
||||
return [
|
||||
'imported' => $imported,
|
||||
'updated' => $updated,
|
||||
'total' => count($designs),
|
||||
'error' => null,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("Sync collection {$collection->id} ({$collection->makerworld_collection_id}) error: ".$e->getMessage());
|
||||
|
||||
$collection->update([
|
||||
'last_synced_at' => now(),
|
||||
'last_sync_status' => 'error',
|
||||
'last_sync_message' => 'Fout bij synchroniseren: '.$e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'imported' => 0,
|
||||
'updated' => 0,
|
||||
'total' => 0,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic sanitization of MakerWorld HTML descriptions.
|
||||
*/
|
||||
|
||||
@@ -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>•</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
|
||||
@@ -44,6 +44,19 @@
|
||||
<span>Producten</span>
|
||||
</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' }}">
|
||||
<i data-lucide="folder-tree" class="w-4 h-4"></i>
|
||||
<span>Categorieën</span>
|
||||
@@ -54,9 +67,9 @@
|
||||
<span>Bestellingen</span>
|
||||
</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>
|
||||
<span>MakerWorld</span>
|
||||
<span>MakerWorld Sync</span>
|
||||
</a>
|
||||
</nav>
|
||||
</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' }}">
|
||||
📦 Producten
|
||||
</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' }}">
|
||||
📁 Categorieën
|
||||
</a>
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
// Automatische MakerWorld Collecties Synchronisatie iedere 10 minuten
|
||||
Schedule::command('app:sync-makerworld-collections')
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
+14
-1
@@ -127,7 +127,7 @@ Route::prefix('admin')->name('admin.')->middleware(['auth', 'admin'])->group(fun
|
||||
Route::get('/orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
|
||||
Route::post('/orders/{order}/status', [AdminOrderController::class, 'updateStatus'])->name('orders.status');
|
||||
|
||||
// MakerWorld Importer
|
||||
// MakerWorld Importer & Collecties Auto-Sync
|
||||
Route::get('/makerworld', [MakerWorldController::class, 'index'])->name('makerworld.index');
|
||||
Route::post('/makerworld/login', [MakerWorldController::class, 'login'])->name('makerworld.login');
|
||||
Route::post('/makerworld/verify-code', [MakerWorldController::class, 'verifyCode'])->name('makerworld.verify-code');
|
||||
@@ -136,4 +136,17 @@ Route::prefix('admin')->name('admin.')->middleware(['auth', 'admin'])->group(fun
|
||||
Route::get('/makerworld/preview', [MakerWorldController::class, 'preview'])->name('makerworld.preview');
|
||||
Route::post('/makerworld/import', [MakerWorldController::class, 'import'])->name('makerworld.import');
|
||||
Route::post('/makerworld/batch-import', [MakerWorldController::class, 'batchImport'])->name('makerworld.batch-import');
|
||||
|
||||
// Collecties Auto-Sync Management
|
||||
Route::post('/makerworld/collections/add', [MakerWorldController::class, 'addCollection'])->name('makerworld.collections.add');
|
||||
Route::post('/makerworld/collections/{collection}/toggle', [MakerWorldController::class, 'toggleCollectionSync'])->name('makerworld.collections.toggle');
|
||||
Route::post('/makerworld/collections/{collection}/sync', [MakerWorldController::class, 'syncCollectionNow'])->name('makerworld.collections.sync');
|
||||
Route::delete('/makerworld/collections/{collection}', [MakerWorldController::class, 'deleteCollection'])->name('makerworld.collections.delete');
|
||||
|
||||
// Artikel Beheer (Staging & Curation voor geïmporteerde modellen)
|
||||
Route::get('/makerworld/staging', [MakerWorldController::class, 'staging'])->name('makerworld.staging');
|
||||
Route::post('/makerworld/staging/{product}/publish', [MakerWorldController::class, 'publishStaged'])->name('makerworld.staging.publish');
|
||||
Route::post('/makerworld/staging/bulk-publish', [MakerWorldController::class, 'bulkPublishStaged'])->name('makerworld.staging.bulk-publish');
|
||||
Route::delete('/makerworld/staging/{product}', [MakerWorldController::class, 'rejectStaged'])->name('makerworld.staging.reject');
|
||||
Route::post('/makerworld/staging/bulk-reject', [MakerWorldController::class, 'bulkRejectStaged'])->name('makerworld.staging.bulk-reject');
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user