components([ Grid::make(3) ->schema([ // ─── Left / Main Column (span 2) ────────────────────── Grid::make(1) ->schema([ // Details Section (Name, Slug, Description) Section::make() ->schema([ Grid::make(2) ->schema([ TextInput::make('name') ->required() ->live(onBlur: true) ->afterStateUpdated(function (Set $set, ?string $state, callable $get) { if (!empty($state)) { $cleanName = preg_replace('/[^\p{L}\p{N}\s-]/u', '', strtolower($state)); $stopWords = [ 'a', 'an', 'the', 'and', 'or', 'of', 'with', 'for', 'in', 'on', 'at', 'to', 'by', 'from', 'is', 'are', 'was', 'were', 'amp', 'এবং', 'ও', 'আর', 'জন্য', 'থেকে', 'পর', 'প্রতি', 'দিয়ে', 'দ্বারা' ]; $words = preg_split('/\s+/', $cleanName); $filteredWords = array_filter($words, function ($word) use ($stopWords) { return !empty($word) && !in_array($word, $stopWords); }); $slug = Str::slug(implode(' ', $filteredWords)); $set('slug', empty($slug) ? Str::slug($state) : $slug); if (empty($get('sku'))) { $set('sku', \App\Models\Product::generateUniqueSku($state)); } } }), TextInput::make('slug') ->required() ->unique(ignoreRecord: true), ]), RichEditor::make('description') ->columnSpanFull(), ]), // Images Section Section::make('Images') ->schema([ SpatieMediaLibraryFileUpload::make('images') ->collection('images') ->disk(config('media-library.disk_name', 'public')) ->multiple() ->reorderable() ->panelLayout('grid') ->helperText('The first image (top-left) will be used as the product thumbnail. Drag and drop to reorder images.') ->maxFiles(10) ->hiddenLabel(), ]) ->collapsible(), // Pricing Section Section::make('Pricing') ->schema([ Grid::make(2) ->schema([ TextInput::make('price') ->label('Price') ->required() ->numeric() ->prefix('৳'), TextInput::make('compare_at_price') ->label('Compare at price') ->numeric() ->prefix('৳'), TextInput::make('cost_price') ->label('Cost per item') ->required() ->numeric() ->prefix('৳') ->helperText("Customers won't see this price.") ->columnSpan(1), ]), ]), // Inventory Section Section::make('Inventory') ->schema([ Grid::make(2) ->schema([ TextInput::make('sku') ->label('SKU (Stock Keeping Unit)') ->required() ->unique(ignoreRecord: true) ->suffixAction( \Filament\Actions\Action::make('generateSku') ->icon('heroicon-m-arrow-path') ->tooltip('Generate SKU from Name') ->action(function (Set $set, callable $get) { $name = $get('name'); if (!empty($name)) { $set('sku', \App\Models\Product::generateUniqueSku($name)); } else { \Filament\Notifications\Notification::make() ->warning() ->title('Please enter a product name first.') ->send(); } }) ), TextInput::make('quantity') ->label('Stock Quantity') ->required() ->numeric() ->default(0), TextInput::make('security_stock') ->label('Safety Stock') ->required() ->numeric() ->default(0) ->hintIcon('heroicon-m-question-mark-circle') ->hintIconTooltip('Low stock alert fires at or below this level.') ->columnSpanFull(), ]), ]), // Shipping Section Section::make('Shipping') ->schema([ Grid::make(2) ->schema([ Checkbox::make('can_be_returned') ->label('This product can be returned') ->default(true), Checkbox::make('will_be_shipped') ->label('This product will be shipped') ->default(true), ]), Toggle::make('is_weight_based') ->label('Enable Weight-Based Shipping') ->default(false) ->live() ->columnSpanFull(), // Weight input + unit selector Grid::make(3) ->schema([ TextInput::make('weight_grams') ->label('Product Weight') ->numeric() ->minValue(0) ->placeholder(fn (callable $get): string => $get('weight_unit') === 'kg' ? 'e.g. 0.5' : 'e.g. 500' ) ->helperText('Used to calculate shipping cost for weight-based methods.') ->afterStateHydrated(function (TextInput $component, $state, callable $set) { if (blank($state)) { $set('weight_unit', 'g'); return; } $grams = (int) $state; if ($grams >= 1000) { $component->state(round($grams / 1000, 3)); $set('weight_unit', 'kg'); } else { $component->state($grams); $set('weight_unit', 'g'); } }) ->dehydrateStateUsing(function ($state, callable $get): ?int { if (blank($state)) return null; // If user typed in kg, convert back to grams for storage if (($get('weight_unit') ?? 'g') === 'kg') { return (int) round((float) $state * 1000); } return (int) $state; }) ->columnSpan(2), Select::make('weight_unit') ->label('Unit') ->options([ 'g' => 'Grams (g)', 'kg' => 'Kilograms (kg)', ]) ->default('g') ->native(false) ->dehydrated(false) // virtual — not saved to DB ->live() ->afterStateUpdated(function ( ?string $state, ?string $old, callable $get, callable $set ): void { $current = (float) ($get('weight_grams') ?? 0); if ($current <= 0) return; if ($state === 'kg' && $old === 'g') { // grams → kg: divide by 1000 $set('weight_grams', round($current / 1000, 3)); } elseif ($state === 'g' && $old === 'kg') { // kg → grams: multiply by 1000 $set('weight_grams', (int) round($current * 1000)); } }) ->columnSpan(1), ]) ->visible(fn (callable $get) => (bool) $get('is_weight_based')), ]), // Product Variants Section Section::make('Product Variants') ->icon('heroicon-m-squares-plus') ->description('Each variant must have a unique option combination.') ->schema([ Repeater::make('variants') ->relationship('variants') ->hiddenLabel() ->collapseAllAction(fn (\Filament\Actions\Action $action) => $action->hidden()) ->expandAllAction(fn (\Filament\Actions\Action $action) => $action->hidden()) ->addActionLabel('+ Add Variant') ->schema([ // ── Row 1: Attributes (full width, most important) ── Repeater::make('options') ->label('Variant Options') ->hintIcon('heroicon-m-question-mark-circle') ->hintIconTooltip('Select unique attributes (e.g. Size, Color) for this variant.') ->addActionLabel('+ Add Attribute (e.g. Size, Color, Weight)') ->schema([ Select::make('attribute_name') ->label('Attribute') ->placeholder('e.g. Size, Color, Weight') ->options(\App\Models\ProductAttribute::pluck('name', 'name')) ->required() ->live() ->disableOptionsWhenSelectedInSiblingRepeaterItems(), Select::make('attribute_value') ->label('Value') ->placeholder('← Select an attribute first') ->options(fn (callable $get) => \App\Models\ProductAttributeValue::whereHas('attribute', fn ($q) => $q->where('name', $get('attribute_name'))) ->pluck('value', 'value') ) ->required() ->live(), ]) ->columns(2) ->columnSpanFull() ->maxItems(5) ->defaultItems(1) ->reorderable(false) ->live() ->afterStateUpdated(function (Set $set, callable $get) { $currentSku = $get('sku'); if (empty($currentSku)) { $mainSku = $get('../../sku') ?? 'PROD'; $options = $get('options') ?? []; $parts = []; foreach ($options as $opt) { if (!empty($opt['attribute_value'])) { $parts[] = strtoupper(str_replace(' ', '', $opt['attribute_value'])); } } if (!empty($parts)) { $set('sku', "{$mainSku}-" . implode('-', $parts)); } } }) ->afterStateHydrated(function (Repeater $component, $state) { // Convert {"Size": "L", "Color": "Blue"} → [{attribute_name, attribute_value}] if (is_array($state) && !empty($state)) { $firstItem = reset($state); if (!is_array($firstItem)) { $formatted = []; foreach ($state as $key => $val) { $formatted[] = [ 'attribute_name' => $key, 'attribute_value' => $val, ]; } $component->state($formatted); } } $component->hydrateItems(); }), // ── Row 2: SKU ── TextInput::make('sku') ->label('Variant SKU') ->hintIcon('heroicon-m-question-mark-circle') ->hintIconTooltip('Auto-generated variant code. Editable.') ->required() ->unique(ignoreRecord: true) ->columnSpan(1) ->suffixAction( \Filament\Actions\Action::make('generateVariantSku') ->icon('heroicon-m-arrow-path') ->tooltip('Re-generate SKU from options') ->action(function (Set $set, callable $get) { $mainSku = $get('../../sku') ?? 'PROD'; $options = $get('options') ?? []; $parts = []; foreach ($options as $opt) { if (!empty($opt['attribute_value'])) { $parts[] = strtoupper(str_replace(' ', '', $opt['attribute_value'])); } } $suffix = !empty($parts) ? implode('-', $parts) : random_int(100, 999); $set('sku', "{$mainSku}-{$suffix}"); }) ), // ── Row 3: Stock & Price ── TextInput::make('quantity') ->label('Stock Qty') ->numeric() ->required() ->default(0) ->minValue(0) ->suffix('pcs'), TextInput::make('price') ->label('Price Override (optional)') ->numeric() ->prefix('৳') ->hintIcon('heroicon-m-question-mark-circle') ->hintIconTooltip('Leave empty to use the base product price.'), TextInput::make('security_stock') ->label('Safety Stock') ->numeric() ->nullable() ->default(0) ->hintIcon('heroicon-m-question-mark-circle') ->hintIconTooltip('Low stock alert fires at or below this level.'), ]) ->columns(2) ->itemLabel(fn (array $state): ?string => ($state['sku'] ?? 'New Variant') . (isset($state['options']) && is_array($state['options']) ? ' (' . implode(', ', array_map(function ($k, $v) { if (is_array($v)) { return ($v['attribute_name'] ?? '') . ': ' . ($v['attribute_value'] ?? ''); } return "$k: $v"; }, array_keys($state['options']), $state['options'])) . ')' : '') ) ->defaultItems(0) ->columnSpanFull() ->collapsed() ->cloneable(), ]) ->collapsible(), // SEO Settings Section Section::make('SEO Settings') ->schema([ TextInput::make('seo_title') ->label('Meta Title') ->placeholder('Defaults to product name'), Textarea::make('seo_description') ->label('Meta Description') ->rows(3) ->columnSpanFull(), ]) ->collapsible() ->collapsed(), ]) ->columnSpan(2), // ─── Right Sidebar Column (span 1) ─────────────────── Grid::make(1) ->schema([ // Status Section Section::make('Status') ->schema([ Toggle::make('is_visible') ->label('Visibility') ->helperText('This product will be hidden from all sales channels.') ->default(true), DatePicker::make('published_at') ->label('Publishing date') ->required() ->default(now()), ]), // Associations Section Section::make('Associations') ->schema([ Select::make('brand_id') ->relationship('brand', 'name') ->label('Brand') ->searchable(), Select::make('category_id') ->relationship('category', 'name') ->label('Product categories') ->required() ->searchable(), ]), ]) ->columnSpan(1), ]) ->columnSpanFull(), ]); } }