Explorar el Código

Merge branch 'variant' into dev-rewardPoints

bianjunhui hace 2 semanas
padre
commit
b4d60b5673
Se han modificado 100 ficheros con 9279 adiciones y 574 borrados
  1. 6 1
      README.md
  2. 7 1
      bootstrap/app.php
  3. 1 0
      bootstrap/providers.php
  4. 15 8
      composer.json
  5. 2077 126
      composer.lock
  6. 230 0
      config/api-platform.php
  7. 84 0
      config/flexible_variant.php
  8. 2 2
      packages/Longyi/Core/INSTALLATION.md
  9. 2 2
      packages/Longyi/Core/MODULE_SUMMARY.md
  10. 7 2
      packages/Longyi/Core/README.md
  11. 5 10
      packages/Longyi/Core/src/Contracts/ProductVariant.php
  12. 1 1
      packages/Longyi/Core/src/Database/Migrations/2024_01_01_000001_create_product_options_table.php
  13. 128 0
      packages/Longyi/Core/src/Database/Migrations/2026_02_26_000001_refactor_price_indices_to_polymorphic.php
  14. 36 0
      packages/Longyi/Core/src/Database/Migrations/2026_03_07_000001_create_product_variant_images_table.php
  15. 14 10
      packages/Longyi/Core/src/Helpers/FlexibleVariantOption.php
  16. 274 0
      packages/Longyi/Core/src/Helpers/Indexers/Price/FlexibleVariant.php
  17. 353 7
      packages/Longyi/Core/src/Http/Controllers/Admin/FlexibleVariantController.php
  18. 8 0
      packages/Longyi/Core/src/Models/ProductOption.php
  19. 8 1
      packages/Longyi/Core/src/Models/ProductOptionValue.php
  20. 77 19
      packages/Longyi/Core/src/Models/ProductVariant.php
  21. 3 0
      packages/Longyi/Core/src/Providers/LongyiCoreServiceProvider.php
  22. 31 9
      packages/Longyi/Core/src/Repositories/ProductVariantRepository.php
  23. 2 0
      packages/Longyi/Core/src/Resources/lang/en/app.php
  24. 0 268
      packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible-variant.blade.php
  25. 1004 0
      packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible_variant.blade.php
  26. 144 0
      packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible_variant/edit.blade.php
  27. 31 0
      packages/Longyi/Core/src/Resources/views/components/admin/nesteddraggable.blade.php
  28. 1 0
      packages/Longyi/Core/src/Resources/views/components/test.blade.php
  29. 3 1
      packages/Longyi/Core/src/Routes/admin-routes.php
  30. 93 82
      packages/Longyi/Core/src/Type/FlexibleVariant.php
  31. 2 0
      packages/Webkul/Admin/package.json
  32. 51 0
      packages/Webkul/Admin/readme.md
  33. 14 13
      packages/Webkul/Admin/src/Http/Controllers/Catalog/ProductController.php
  34. 30 2
      packages/Webkul/Admin/src/Resources/assets/css/app.css
  35. 3 0
      packages/Webkul/Admin/src/Resources/assets/css/flexible_variant.css
  36. 125 0
      packages/Webkul/Admin/src/Resources/assets/js/VueComponents/LongyiOverlay.vue
  37. 18 0
      packages/Webkul/Admin/src/Resources/assets/js/VueComponents/readme.md
  38. 13 0
      packages/Webkul/Admin/src/Resources/assets/js/app.js
  39. 2 2
      packages/Webkul/Admin/src/Resources/assets/js/plugins/vee-validate.js
  40. 10 0
      packages/Webkul/Admin/src/Resources/assets/js/plugins/vuedraggableplus.js
  41. 27 0
      packages/Webkul/Admin/src/Resources/assets/js/stores/overlayManager.js
  42. 17 5
      packages/Webkul/Admin/src/Resources/views/catalog/products/edit.blade.php
  43. 1 1
      packages/Webkul/Admin/src/Resources/views/components/media/images.blade.php
  44. 25 1
      packages/Webkul/Admin/tailwind.config.js
  45. 1 0
      packages/Webkul/Admin/vite.config.js
  46. 103 0
      packages/Webkul/BagistoApi/README.md
  47. 34 0
      packages/Webkul/BagistoApi/composer.json
  48. 228 0
      packages/Webkul/BagistoApi/config/api-platform-vendor.php
  49. 230 0
      packages/Webkul/BagistoApi/config/api-platform.php
  50. 78 0
      packages/Webkul/BagistoApi/config/graphql-auth.php
  51. 54 0
      packages/Webkul/BagistoApi/config/storefront.php
  52. 19 0
      packages/Webkul/BagistoApi/src/Attributes/AllowPublic.php
  53. 19 0
      packages/Webkul/BagistoApi/src/Attributes/RequiresStorefrontKey.php
  54. 113 0
      packages/Webkul/BagistoApi/src/CacheProfiles/ApiAwareResponseCache.php
  55. 141 0
      packages/Webkul/BagistoApi/src/Console/Commands/ApiKeyMaintenanceCommand.php
  56. 316 0
      packages/Webkul/BagistoApi/src/Console/Commands/ApiKeyManagementCommand.php
  57. 45 0
      packages/Webkul/BagistoApi/src/Console/Commands/ClearApiPlatformCacheCommand.php
  58. 81 0
      packages/Webkul/BagistoApi/src/Console/Commands/GenerateStorefrontKey.php
  59. 504 0
      packages/Webkul/BagistoApi/src/Console/Commands/InstallApiPlatformCommand.php
  60. 5 0
      packages/Webkul/BagistoApi/src/Contracts/GuestCartTokens.php
  61. 26 0
      packages/Webkul/BagistoApi/src/Database/Migrations/2025_12_10_185743_create_cart_tokens_table.php
  62. 68 0
      packages/Webkul/BagistoApi/src/Database/Migrations/2026_01_08_000000_create_storefront_keys_table.php
  63. 60 0
      packages/Webkul/BagistoApi/src/Dto/AddToCartInput.php
  64. 21 0
      packages/Webkul/BagistoApi/src/Dto/ApplyCouponInput.php
  65. 26 0
      packages/Webkul/BagistoApi/src/Dto/CancelOrderInput.php
  66. 353 0
      packages/Webkul/BagistoApi/src/Dto/CartData.php
  67. 287 0
      packages/Webkul/BagistoApi/src/Dto/CartInput.php
  68. 167 0
      packages/Webkul/BagistoApi/src/Dto/CartItemData.php
  69. 124 0
      packages/Webkul/BagistoApi/src/Dto/CheckoutAddressInput.php
  70. 116 0
      packages/Webkul/BagistoApi/src/Dto/CheckoutAddressOutput.php
  71. 31 0
      packages/Webkul/BagistoApi/src/Dto/CheckoutAddressPayload.php
  72. 11 0
      packages/Webkul/BagistoApi/src/Dto/CheckoutAddressQueryInput.php
  73. 28 0
      packages/Webkul/BagistoApi/src/Dto/ContactUsInput.php
  74. 20 0
      packages/Webkul/BagistoApi/src/Dto/ContactUsOutput.php
  75. 20 0
      packages/Webkul/BagistoApi/src/Dto/CreateCompareItemInput.php
  76. 58 0
      packages/Webkul/BagistoApi/src/Dto/CreateProductReviewInput.php
  77. 20 0
      packages/Webkul/BagistoApi/src/Dto/CreateWishlistInput.php
  78. 117 0
      packages/Webkul/BagistoApi/src/Dto/CustomerAddressInput.php
  79. 56 0
      packages/Webkul/BagistoApi/src/Dto/CustomerAddressOutput.php
  80. 14 0
      packages/Webkul/BagistoApi/src/Dto/CustomerLoginInput.php
  81. 120 0
      packages/Webkul/BagistoApi/src/Dto/CustomerProfileInput.php
  82. 116 0
      packages/Webkul/BagistoApi/src/Dto/CustomerProfileOutput.php
  83. 52 0
      packages/Webkul/BagistoApi/src/Dto/CustomerVerifyOutput.php
  84. 12 0
      packages/Webkul/BagistoApi/src/Dto/DeleteAllCompareItemsInput.php
  85. 12 0
      packages/Webkul/BagistoApi/src/Dto/DeleteAllWishlistsInput.php
  86. 19 0
      packages/Webkul/BagistoApi/src/Dto/DeleteCompareItemInput.php
  87. 19 0
      packages/Webkul/BagistoApi/src/Dto/DeleteWishlistInput.php
  88. 21 0
      packages/Webkul/BagistoApi/src/Dto/DestroySelectedInput.php
  89. 29 0
      packages/Webkul/BagistoApi/src/Dto/DownloadLinkOutput.php
  90. 60 0
      packages/Webkul/BagistoApi/src/Dto/EstimateShippingMethodsInput.php
  91. 13 0
      packages/Webkul/BagistoApi/src/Dto/ForgotPasswordInput.php
  92. 21 0
      packages/Webkul/BagistoApi/src/Dto/GenerateDownloadLinkInput.php
  93. 32 0
      packages/Webkul/BagistoApi/src/Dto/LoginInput.php
  94. 23 0
      packages/Webkul/BagistoApi/src/Dto/LogoutInput.php
  95. 23 0
      packages/Webkul/BagistoApi/src/Dto/LogoutOutput.php
  96. 34 0
      packages/Webkul/BagistoApi/src/Dto/MoveToWishlistInput.php
  97. 25 0
      packages/Webkul/BagistoApi/src/Dto/MoveWishlistToCartInput.php
  98. 27 0
      packages/Webkul/BagistoApi/src/Dto/MoveWishlistToCartOutput.php
  99. 42 0
      packages/Webkul/BagistoApi/src/Dto/PaymentMethodOutput.php
  100. 0 0
      packages/Webkul/BagistoApi/src/Dto/RemoveFromCartInput.php

+ 6 - 1
README.md

@@ -78,7 +78,7 @@ Empower your e-commerce journey with the [Bagisto Starter Pack](https://store.we
 
 
 # Open Source B2B eCommerce Platform
 # Open Source B2B eCommerce Platform
 
 
-The [B2B eCommerce Platform](https://bagisto.com/en/b2b-commerce-platform/) enhances your Bagisto store with advanced Business-to-Business (B2B) features. It enables company-based purchasing, multi-user access, quote negotiation, and procurement management — empowering businesses to handle B2B workflows efficiently within a single platform.
+The [B2B  eCommerce Platform](https://bagisto.com/en/b2b-commerce-platform/) enhances your Bagisto store with advanced Business-to-Business (B2B) features. It enables company-based purchasing, multi-user access, quote negotiation, and procurement management — empowering businesses to handle B2B workflows efficiently within a single platform.
 
 
 ![Bagisto B2B Ecommerce Image](https://github.com/bagisto/temp-media/blob/master/intro-banner.webp)
 ![Bagisto B2B Ecommerce Image](https://github.com/bagisto/temp-media/blob/master/intro-banner.webp)
 
 
@@ -174,3 +174,8 @@ Thank you to all our backers! 🙏
 Support this project by becoming a sponsor. Your logo will show up here with a link to your website.
 Support this project by becoming a sponsor. Your logo will show up here with a link to your website.
 
 
 <a href="https://opencollective.com/bagisto" target="_blank"><img src="https://opencollective.com/bagisto/sponsors.svg?width=890&isActive=true"></a>
 <a href="https://opencollective.com/bagisto" target="_blank"><img src="https://opencollective.com/bagisto/sponsors.svg?width=890&isActive=true"></a>
+
+
+
+`packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible_variant.blade.php` 模板中使用`@bagistoVite`引入了`flexible_variant.css`,在浏览器中打开网站时只有该页面会加载`flexible_variant.css`的代码,其他页面不会加载(按需加载)。
+要想`flexible_variant.css`能被引用,需要在`packages/Webkul/Admin/vite.config.js`中配置该文件的路径,将其纳入vite的覆盖范围内。

+ 7 - 1
bootstrap/app.php

@@ -48,4 +48,10 @@ return Application::configure(basePath: dirname(__DIR__))
     })
     })
     ->withExceptions(function (Exceptions $exceptions) {
     ->withExceptions(function (Exceptions $exceptions) {
         //
         //
-    })->create();
+    })
+->withProviders([
+     \ApiPlatform\Laravel\ApiPlatformProvider::class,
+     \ApiPlatform\Laravel\ApiPlatformDeferredProvider::class,
+     \ApiPlatform\Laravel\Eloquent\ApiPlatformEventProvider::class,
+])
+->create();

+ 1 - 0
bootstrap/providers.php

@@ -46,4 +46,5 @@ return [
     Webkul\Tax\Providers\TaxServiceProvider::class,
     Webkul\Tax\Providers\TaxServiceProvider::class,
     Webkul\Theme\Providers\ThemeServiceProvider::class,
     Webkul\Theme\Providers\ThemeServiceProvider::class,
     Webkul\User\Providers\UserServiceProvider::class,
     Webkul\User\Providers\UserServiceProvider::class,
+    Webkul\BagistoApi\Providers\BagistoApiServiceProvider::class,
 ];
 ];

+ 15 - 8
composer.json

@@ -17,6 +17,8 @@
         "ext-pdo": "*",
         "ext-pdo": "*",
         "ext-pdo_mysql": "*",
         "ext-pdo_mysql": "*",
         "ext-tokenizer": "*",
         "ext-tokenizer": "*",
+        "api-platform/graphql": "v4.2.3",
+        "api-platform/laravel": "v4.1.25",
         "astrotomic/laravel-translatable": "^11.0.0",
         "astrotomic/laravel-translatable": "^11.0.0",
         "bagisto/image-cache": "dev-master",
         "bagisto/image-cache": "dev-master",
         "barryvdh/laravel-dompdf": "^2.0.0",
         "barryvdh/laravel-dompdf": "^2.0.0",
@@ -98,7 +100,9 @@
             "Webkul\\SocialShare\\": "packages/Webkul/SocialShare/src",
             "Webkul\\SocialShare\\": "packages/Webkul/SocialShare/src",
             "Webkul\\Tax\\": "packages/Webkul/Tax/src",
             "Webkul\\Tax\\": "packages/Webkul/Tax/src",
             "Webkul\\Theme\\": "packages/Webkul/Theme/src",
             "Webkul\\Theme\\": "packages/Webkul/Theme/src",
-            "Webkul\\User\\": "packages/Webkul/User/src"
+            "Webkul\\User\\": "packages/Webkul/User/src",
+            "Webkul\\BagistoApi\\": "packages/Webkul/BagistoApi/src",
+            "Webkul\\GraphQL\\": "packages/Webkul/GraphQL/src"
         }
         }
     },
     },
     "autoload-dev": {
     "autoload-dev": {
@@ -126,17 +130,20 @@
             "dont-discover": [
             "dont-discover": [
                 "intervention/image",
                 "intervention/image",
                 "laravel/socialite",
                 "laravel/socialite",
-                "shetabit/visitor"
+                "shetabit/visitor",
+                "api-platform/laravel"
             ]
             ]
         }
         }
     },
     },
-    "repositories": [{
-        "type": "path",
-        "url": "packages/*/*",
-        "options": {
-            "symlink": true
+    "repositories": [
+        {
+            "type": "path",
+            "url": "packages/*/*",
+            "options": {
+                "symlink": true
+            }
         }
         }
-    }],
+    ],
     "config": {
     "config": {
         "optimize-autoloader": true,
         "optimize-autoloader": true,
         "preferred-install": "dist",
         "preferred-install": "dist",

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 2077 - 126
composer.lock


+ 230 - 0
config/api-platform.php

@@ -0,0 +1,230 @@
+<?php
+
+/*
+ * This file is part of the API Platform project.
+ *
+ * (c) Kévin Dunglas <dunglas@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+use ApiPlatform\Metadata\Operation\DashPathSegmentNameGenerator;
+use ApiPlatform\Metadata\UrlGeneratorInterface;
+use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Auth\AuthenticationException;
+use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter;
+use Webkul\BagistoApi\Exception\InvalidInputException;
+use Webkul\BagistoApi\Exception\ValidationException;
+
+return [
+    'title'       => '',
+    'description' => '',
+    'version'     => '1.0.0',
+    'show_webby'  => true,
+
+    'routes' => [
+        'domain' => null,
+        // Global middleware applied to every API Platform routes
+        // HandleInvalidInputException: Catches validation errors and returns RFC 7807 format
+        // VerifyStorefrontKey: Validates X-STOREFRONT-KEY header and rate limiting for shop APIs
+        // BagistoApiDocumentationMiddleware: Handles custom /api index and documentation pages
+        // ForceApiJson: Ensures API responses have JSON content-type
+        // CacheResponse: Using custom ApiAwareResponseCache profile that:
+        // - Excludes API routes from caching (APIs need fresh data)
+        // - Caches shop pages for performance
+        // - Only caches HTML, not JSON responses
+        'middleware' => [
+            'Webkul\BagistoApi\Http\Middleware\HandleInvalidInputException',
+            'Webkul\BagistoApi\Http\Middleware\SecurityHeaders',
+            'Webkul\BagistoApi\Http\Middleware\LogApiRequests',
+            'Webkul\BagistoApi\Http\Middleware\SetLocaleChannel',
+            'Webkul\BagistoApi\Http\Middleware\VerifyStorefrontKey',
+            'Webkul\BagistoApi\Http\Middleware\BagistoApiDocumentationMiddleware',
+            'Webkul\BagistoApi\Http\Middleware\ForceApiJson',
+            'Spatie\ResponseCache\Middlewares\CacheResponse',
+        ],
+    ],
+
+    'resources' => [
+        base_path('packages/Webkul/BagistoApi/src/Models/'),
+    ],
+
+    'formats' => [
+        'json'=> ['application/json'],
+    ],
+
+    'patch_formats' => [
+        'json' => ['application/merge-patch+json'],
+    ],
+
+    'docs_formats' => [
+        'jsonopenapi' => ['application/vnd.openapi+json'],
+        'html'        => ['text/html'],
+    ],
+
+    'error_formats' => [
+        'jsonproblem' => ['application/problem+json'],
+    ],
+
+    'defaults' => [
+        'pagination_enabled'                => true,
+        'pagination_partial'                => false,
+        'pagination_client_enabled'         => false,
+        'pagination_client_items_per_page'  => false,
+        'pagination_client_partial'         => false,
+        'pagination_items_per_page'         => 10,
+        'pagination_maximum_items_per_page' => 50,
+        'route_prefix'                      => '/api',
+        'middleware'                        => [],
+    ],
+
+    'pagination' => [
+        'page_parameter_name'           => 'page',
+        'enabled_parameter_name'        => 'pagination',
+        'items_per_page_parameter_name' => 'itemsPerPage',
+        'partial_parameter_name'        => 'partial',
+    ],
+
+    'graphql' => [
+        'enabled'              => true,
+        'nesting_separator'    => '__',
+        'introspection'        => ['enabled' => true],
+        'max_query_complexity' => 400,
+        'max_query_depth'      => 20,
+        'graphiql'             => [
+            'enabled'           => true,
+            'default_query'     => null,
+            'default_variables' => null,
+        ],
+        'graphql_playground' => [
+            'enabled'           => true,
+            'default_query'     => null,
+            'default_variables' => null,
+        ],
+        // GraphQL middleware for authentication and rate limiting
+        'middleware' => [
+            'Webkul\BagistoApi\Http\Middleware\SetLocaleChannel',
+            'Webkul\BagistoApi\Http\Middleware\VerifyGraphQLStorefrontKey',
+        ],
+    ],
+
+    'graphiql' => [
+        'enabled' => true,
+    ],
+
+    'name_converter' => SnakeCaseToCamelCaseNameConverter::class,
+
+    'path_segment_name_generator' => DashPathSegmentNameGenerator::class,
+
+    'exception_to_status' => [
+        AuthenticationException::class => 401,
+        AuthorizationException::class  => 403,
+        ValidationException::class     => 400,
+        InvalidInputException::class   => 400,
+    ],
+
+    'swagger_ui' => [
+        'enabled' => true,
+        'apiKeys' => [
+            'api' => [
+                'name'   => 'Authorization',
+                'type'   => 'header',
+                'scheme' => 'bearer',
+            ],
+        ],
+    ],
+
+    'url_generation_strategy' => UrlGeneratorInterface::ABS_PATH,
+
+    'serializer' => [
+        'hydra_prefix'    => false,
+        'datetime_format' => 'Y-m-d\TH:i:sP',
+    ],
+
+    'cache' => 'redis',
+
+    'schema_cache' => [
+        'enabled' => true,
+        'store'   => 'redis',
+    ],
+
+    'security' => [
+        'sanctum' => true,
+    ],
+
+    'rate_limit' => [
+        'skip_localhost' => env('RATE_LIMIT_SKIP_LOCALHOST', true),
+        'auth'           => env('RATE_LIMIT_AUTH', 5),
+        'admin'          => env('RATE_LIMIT_ADMIN', 60),
+        'shop'           => env('RATE_LIMIT_SHOP', 100),
+        'graphql'        => env('RATE_LIMIT_GRAPHQL', 100),
+        'cache_driver'   => env('RATE_LIMIT_CACHE', 'redis'),
+        'cache_prefix'   => 'api:rate-limit:',
+    ],
+
+    'security_headers' => [
+        'enabled'     => true,
+        'force_https' => env('APP_FORCE_HTTPS', false),
+        'csp_header'  => "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
+    ],
+
+    'api_logging' => [
+        'enabled'            => env('API_LOG_ENABLED', true),
+        'log_sensitive_data' => env('API_LOG_SENSITIVE_DATA', false),
+        'exclude_paths'      => ['docs', 'graphiql', 'swagger-ui', 'docs.json'],
+        'channel'            => 'api',
+        'async'              => env('API_LOG_ASYNC', true),
+        'queue'              => env('API_LOG_QUEUE', 'api-logs'),
+    ],
+
+    'graphql_validation' => [
+        'max_depth'      => env('GRAPHQL_MAX_DEPTH', 10),
+        'max_complexity' => env('GRAPHQL_MAX_COMPLEXITY', 300),
+    ],
+
+    'request_limits' => [
+        'max_size_mb'          => env('MAX_REQUEST_SIZE', 10),
+        'max_pagination_limit' => env('MAX_PAGINATION_LIMIT', 100),
+    ],
+
+    'database' => [
+        'log_queries'          => env('DB_QUERY_LOG_ENABLED', false),
+        'slow_query_threshold' => env('DB_SLOW_QUERY_THRESHOLD', 1000),
+    ],
+
+    'caching' => [
+        'enable_security_cache' => env('API_SECURITY_CACHE', true),
+        'security_cache_ttl'    => env('API_SECURITY_CACHE_TTL', 3600),
+        'enable_response_cache' => env('API_RESPONSE_CACHE', true),
+        'response_cache_ttl'    => env('API_RESPONSE_CACHE_TTL', 3600),
+    ],
+
+    'http_cache' => [
+        'etag'                   => true,
+        'max_age'                => 3600,
+        'shared_max_age'         => null,
+        'vary'                   => null,
+        'public'                 => true,
+        'stale_while_revalidate' => 30,
+        'stale_if_error'         => null,
+        'invalidation'           => [
+            'urls'              => [],
+            'scoped_clients'    => [],
+            'max_header_length' => 7500,
+            'request_options'   => [],
+            'purger'            => ApiPlatform\HttpCache\SouinPurger::class,
+        ],
+    ],
+
+    'key_rotation_policy' => [
+        'enabled'               => true,
+        'expiration_months'     => env('API_KEY_EXPIRATION_MONTHS', 12),
+        'transition_days'       => env('API_KEY_TRANSITION_DAYS', 7),
+        'cleanup_days'          => env('API_KEY_CLEANUP_DAYS', 90),
+        'cache_ttl'             => env('API_KEY_CACHE_TTL', 3600),
+        'storefront_key_prefix' => env('STOREFRONT_KEY_PREFIX', 'pk_storefront_'),
+    ],
+];

+ 84 - 0
config/flexible_variant.php

@@ -0,0 +1,84 @@
+<?php
+
+return [
+    /*
+    |--------------------------------------------------------------------------
+    | Flexible Variant Configuration
+    |--------------------------------------------------------------------------
+    |
+    | Configuration for the Flexible Variant product type
+    |
+    */
+
+    /*
+     * Enable or disable flexible variant functionality
+     */
+    'enabled' => env('FLEXIBLE_VARIANT_ENABLED', true),
+
+    /*
+     * Default option types
+     */
+    'option_types' => [
+        'select' => 'Select Dropdown',
+        'radio' => 'Radio Buttons',
+        'checkbox' => 'Checkboxes',
+        'color' => 'Color Swatches',
+        'button' => 'Button Select',
+    ],
+
+    /*
+     * Auto-generate variant SKU based on parent SKU + option codes
+     */
+    'auto_generate_sku' => env('FLEXIBLE_VARIANT_AUTO_SKU', false),
+
+    /*
+     * SKU separator for auto-generated SKUs
+     * Example: PARENT-SKU-RED-LARGE
+     */
+    'sku_separator' => '-',
+
+    /*
+     * Default sort order for new variants
+     */
+    'default_sort_order' => 0,
+
+    /*
+     * Enable soft deletes for variants
+     */
+    'soft_delete' => true,
+
+    /*
+     * Maximum number of options per product
+     */
+    'max_options_per_product' => 10,
+
+    /*
+     * Maximum number of values per option
+     */
+    'max_values_per_option' => 50,
+
+    /*
+     * Maximum number of variants per product
+     */
+    'max_variants_per_product' => 500,
+
+    /*
+     * Default image placeholder for variants without images
+     */
+    'default_variant_image' => null,
+
+    /*
+     * Enable inventory tracking for variants
+     */
+    'track_inventory' => true,
+
+    /*
+     * Allow backorders for out-of-stock variants
+     */
+    'allow_backorders' => false,
+
+    /*
+     * Automatically disable variants when quantity reaches zero
+     */
+    'auto_disable_on_zero_stock' => false,
+];

+ 2 - 2
packages/Longyi/Core/INSTALLATION.md

@@ -241,13 +241,13 @@ $variant = $variantRepo->createWithOptions([
 ```php
 ```php
 $saleableVariants = \Longyi\Core\Models\ProductVariant::where('product_id', 1)
 $saleableVariants = \Longyi\Core\Models\ProductVariant::where('product_id', 1)
     ->saleable()
     ->saleable()
-    ->with('optionValues.option')
+    ->with('values.option')
     ->get();
     ->get();
 
 
 foreach ($saleableVariants as $variant) {
 foreach ($saleableVariants as $variant) {
     echo $variant->sku . ': ' . $variant->getEffectivePrice() . PHP_EOL;
     echo $variant->sku . ': ' . $variant->getEffectivePrice() . PHP_EOL;
     
     
-    foreach ($variant->optionValues as $optionValue) {
+    foreach ($variant->values as $optionValue) {
         echo '  - ' . $optionValue->option->label . ': ' . $optionValue->label . PHP_EOL;
         echo '  - ' . $optionValue->option->label . ': ' . $optionValue->label . PHP_EOL;
     }
     }
 }
 }

+ 2 - 2
packages/Longyi/Core/MODULE_SUMMARY.md

@@ -309,14 +309,14 @@ $variant = ProductVariant::create([
     'status' => true,
     'status' => true,
 ]);
 ]);
 
 
-$variant->optionValues()->attach([$redValue->id, $mediumValue->id]);
+$variant->values()->attach([$redValue->id, $mediumValue->id]);
 ```
 ```
 
 
 ### Query Saleable Variants
 ### Query Saleable Variants
 ```php
 ```php
 $variants = ProductVariant::where('product_id', 1)
 $variants = ProductVariant::where('product_id', 1)
     ->saleable()
     ->saleable()
-    ->with('optionValues.option')
+    ->with('values.option')
     ->get();
     ->get();
 ```
 ```
 
 

+ 7 - 2
packages/Longyi/Core/README.md

@@ -127,7 +127,7 @@ $variant = ProductVariant::create([
 ]);
 ]);
 
 
 // Link to option values
 // Link to option values
-$variant->optionValues()->attach([$redValue->id, $mediumValue->id]);
+$variant->values()->attach([$redValue->id, $mediumValue->id]);
 ```
 ```
 
 
 ### Query Saleable Variants
 ### Query Saleable Variants
@@ -136,7 +136,7 @@ $variant->optionValues()->attach([$redValue->id, $mediumValue->id]);
 $saleableVariants = ProductVariant::where('product_id', 1)
 $saleableVariants = ProductVariant::where('product_id', 1)
     ->where('status', true)
     ->where('status', true)
     ->where('quantity', '>', 0)
     ->where('quantity', '>', 0)
-    ->with('optionValues')
+    ->with('values')
     ->orderBy('sort_order')
     ->orderBy('sort_order')
     ->get();
     ->get();
 ```
 ```
@@ -169,3 +169,8 @@ MIT License
 ## Support
 ## Support
 
 
 For issues and questions, please open an issue on GitHub.
 For issues and questions, please open an issue on GitHub.
+
+
+## components
+
+`Resources/views/components`中是Blade组件

+ 5 - 10
packages/Longyi/Core/src/Contracts/ProductVariant.php

@@ -12,7 +12,7 @@ interface ProductVariant
     /**
     /**
      * Get all option values for this variant
      * Get all option values for this variant
      */
      */
-    public function optionValues();
+    public function values();
 
 
     /**
     /**
      * Check if variant is saleable
      * Check if variant is saleable
@@ -20,17 +20,12 @@ interface ProductVariant
     public function isSaleable(): bool;
     public function isSaleable(): bool;
 
 
     /**
     /**
-     * Get the effective price (considering special price)
+     * Get the effective price from price index (or fallback to basic calculation).
      */
      */
-    public function getEffectivePrice(): float;
+    public function getEffectivePrice(?int $customerGroupId = null, ?int $channelId = null): float;
 
 
     /**
     /**
-     * Get images as array
+     * Get images for this variant (many-to-many with product_images).
      */
      */
-    public function getImagesAttribute($value);
-
-    /**
-     * Set images from array
-     */
-    public function setImagesAttribute($value);
+    public function images();
 }
 }

+ 1 - 1
packages/Longyi/Core/src/Database/Migrations/2024_01_01_000001_create_product_options_table.php

@@ -15,7 +15,7 @@ return new class extends Migration
             $table->increments('id');
             $table->increments('id');
             $table->string('label')->comment('选项标签 (如: 颜色)');
             $table->string('label')->comment('选项标签 (如: 颜色)');
             $table->string('type', 50)->default('select')->comment('select, radio, checkbox, color, button');
             $table->string('type', 50)->default('select')->comment('select, radio, checkbox, color, button');
-            $table->string('code', 100)->unique()->comment('选项代码 (如: color)');
+            $table->string('code', 100)->nullable()->comment('选项代码 (如: color)');
             $table->integer('position')->default(0)->comment('排序位置');
             $table->integer('position')->default(0)->comment('排序位置');
             $table->json('meta')->nullable()->comment('元数据(图标、样式等)');
             $table->json('meta')->nullable()->comment('元数据(图标、样式等)');
             $table->timestamps();
             $table->timestamps();

+ 128 - 0
packages/Longyi/Core/src/Database/Migrations/2026_02_26_000001_refactor_price_indices_to_polymorphic.php

@@ -0,0 +1,128 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        // ── product_price_indices ──────────────────────────────────
+
+        Schema::table('product_price_indices', function (Blueprint $table) {
+            // Drop foreign keys
+            $table->dropForeign(['product_id']);
+
+            // Drop unique & normal indexes that reference product_id
+            if (Schema::hasIndex('product_price_indices', 'price_indices_product_id_customer_group_id_channel_id_unique')) {
+                $table->dropUnique('price_indices_product_id_customer_group_id_channel_id_unique');
+            }
+
+            if (Schema::hasIndex('product_price_indices', 'ppi_product_id_customer_group_id_idx')) {
+                $table->dropIndex('ppi_product_id_customer_group_id_idx');
+            }
+
+            // Rename column
+            $table->renameColumn('product_id', 'priceable_id');
+        });
+
+        Schema::table('product_price_indices', function (Blueprint $table) {
+            // Add morph type column
+            $table->string('priceable_type')->after('priceable_id')->default('');
+
+            // New indexes
+            $table->unique(
+                ['priceable_id', 'priceable_type', 'customer_group_id', 'channel_id'],
+                'ppi_priceable_group_channel_unique'
+            );
+            $table->index(
+                ['priceable_type', 'priceable_id'],
+                'ppi_priceable_morph_idx'
+            );
+        });
+
+        // Backfill existing rows
+        DB::table('product_price_indices')
+            ->where('priceable_type', '')
+            ->update(['priceable_type' => 'Webkul\\Product\\Models\\Product']);
+
+        // Remove default after backfill
+        Schema::table('product_price_indices', function (Blueprint $table) {
+            $table->string('priceable_type')->default(null)->change();
+        });
+
+        // ── product_customer_group_prices ──────────────────────────
+
+        Schema::table('product_customer_group_prices', function (Blueprint $table) {
+            $table->dropForeign(['product_id']);
+
+            $table->renameColumn('product_id', 'priceable_id');
+        });
+
+        Schema::table('product_customer_group_prices', function (Blueprint $table) {
+            $table->string('priceable_type')->after('priceable_id')->default('');
+
+            $table->index(
+                ['priceable_type', 'priceable_id'],
+                'pcgp_priceable_morph_idx'
+            );
+        });
+
+        // Backfill
+        DB::table('product_customer_group_prices')
+            ->where('priceable_type', '')
+            ->update(['priceable_type' => 'Webkul\\Product\\Models\\Product']);
+
+        Schema::table('product_customer_group_prices', function (Blueprint $table) {
+            $table->string('priceable_type')->default(null)->change();
+        });
+
+        // Re-generate unique_id to include priceable_type
+        DB::table('product_customer_group_prices')->update([
+            'unique_id' => DB::raw("CONCAT_WS('|', qty, priceable_id, priceable_type, customer_group_id)"),
+        ]);
+    }
+
+    public function down(): void
+    {
+        // ── product_price_indices ──────────────────────────────────
+
+        Schema::table('product_price_indices', function (Blueprint $table) {
+            $table->dropUnique('ppi_priceable_group_channel_unique');
+            $table->dropIndex('ppi_priceable_morph_idx');
+            $table->dropColumn('priceable_type');
+            $table->renameColumn('priceable_id', 'product_id');
+        });
+
+        Schema::table('product_price_indices', function (Blueprint $table) {
+            $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade');
+            $table->unique(
+                ['product_id', 'customer_group_id', 'channel_id'],
+                'price_indices_product_id_customer_group_id_channel_id_unique'
+            );
+            $table->index(
+                ['product_id', 'customer_group_id'],
+                'ppi_product_id_customer_group_id_idx'
+            );
+        });
+
+        // ── product_customer_group_prices ──────────────────────────
+
+        Schema::table('product_customer_group_prices', function (Blueprint $table) {
+            $table->dropIndex('pcgp_priceable_morph_idx');
+            $table->dropColumn('priceable_type');
+            $table->renameColumn('priceable_id', 'product_id');
+        });
+
+        Schema::table('product_customer_group_prices', function (Blueprint $table) {
+            $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade');
+        });
+
+        // Restore unique_id format
+        DB::table('product_customer_group_prices')->update([
+            'unique_id' => DB::raw("CONCAT_WS('|', qty, product_id, customer_group_id)"),
+        ]);
+    }
+};

+ 36 - 0
packages/Longyi/Core/src/Database/Migrations/2026_03_07_000001_create_product_variant_images_table.php

@@ -0,0 +1,36 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('product_variant_images', function (Blueprint $table) {
+            $table->increments('id');
+            $table->unsignedInteger('product_variant_id');
+            $table->unsignedInteger('product_image_id');
+            $table->integer('position')->default(0);
+
+            $table->foreign('product_variant_id')
+                ->references('id')
+                ->on('product_variants')
+                ->onDelete('cascade');
+
+            $table->foreign('product_image_id')
+                ->references('id')
+                ->on('product_images')
+                ->onDelete('cascade');
+
+            $table->unique(['product_variant_id', 'product_image_id'], 'variant_image_unique');
+            $table->index('product_image_id', 'idx_image_id');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('product_variant_images');
+    }
+};

+ 14 - 10
packages/Longyi/Core/src/Helpers/FlexibleVariantOption.php

@@ -160,18 +160,22 @@ class FlexibleVariantOption
             foreach ($option->values as $value) {
             foreach ($option->values as $value) {
                 // Check if this value is used in any saleable variant
                 // Check if this value is used in any saleable variant
                 $isAvailable = $variants->filter(function ($variant) use ($value) {
                 $isAvailable = $variants->filter(function ($variant) use ($value) {
-                    return $variant->isSaleable() && 
-                           $variant->optionValues->contains('id', $value->id);
+                    return $variant->values->contains('id', $value->id);
+                    // return $variant->isSaleable() && 
+                    //        $variant->values->contains('id', $value->id);
                 })->isNotEmpty();
                 })->isNotEmpty();
+                if($isAvailable) {
+                    $optionData['values'][] = [
+                        'id' => $value->id,
+                        'label' => $value->label,
+                        'code' => $value->code,
+                        'position' => $value->position,
+                        'meta' => $value->meta,
+                        'is_available' => $isAvailable,
+                    ];
 
 
-                $optionData['values'][] = [
-                    'id' => $value->id,
-                    'label' => $value->label,
-                    'code' => $value->code,
-                    'position' => $value->position,
-                    'meta' => $value->meta,
-                    'is_available' => $isAvailable,
-                ];
+                }
+                
             }
             }
 
 
             $result[] = $optionData;
             $result[] = $optionData;

+ 274 - 0
packages/Longyi/Core/src/Helpers/Indexers/Price/FlexibleVariant.php

@@ -0,0 +1,274 @@
+<?php
+
+namespace Longyi\Core\Helpers\Indexers\Price;
+
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Longyi\Core\Models\ProductVariant;
+use Webkul\Customer\Repositories\CustomerRepository;
+use Webkul\Product\Helpers\Indexers\Price\AbstractType;
+use Webkul\Product\Repositories\ProductCustomerGroupPriceRepository;
+
+class FlexibleVariant extends AbstractType
+{
+    /**
+     * The variant currently being indexed.
+     */
+    protected ?ProductVariant $variant = null;
+
+    /**
+     * Cached catalog rule products for the parent product, keyed by channel-group.
+     */
+    protected ?array $catalogRuleProductsCache = null;
+
+    public function __construct(
+        protected CustomerRepository $customerRepository,
+        protected ProductCustomerGroupPriceRepository $productCustomerGroupPriceRepository,
+    ) {
+        parent::__construct(
+            $customerRepository,
+            $productCustomerGroupPriceRepository,
+            app(\Webkul\CatalogRule\Repositories\CatalogRuleProductPriceRepository::class),
+        );
+    }
+
+    /**
+     * Set the variant being indexed.
+     */
+    public function setVariant(?ProductVariant $variant): static
+    {
+        $this->variant = $variant;
+
+        return $this;
+    }
+
+    /**
+     * Product-level indices (aggregated from all saleable variants).
+     */
+    public function getIndices(): array
+    {
+        $variants = $this->getSaleableVariants();
+
+        $minPrices = [];
+        $maxPrices = [];
+        $regularMinPrices = [];
+        $regularMaxPrices = [];
+
+        foreach ($variants as $variant) {
+            $vi = $this->setVariant($variant)->getVariantIndices();
+
+            $minPrices[] = $vi['min_price'];
+            $maxPrices[] = $vi['max_price'];
+            $regularMinPrices[] = $vi['regular_min_price'];
+            $regularMaxPrices[] = $vi['regular_max_price'];
+        }
+
+        return [
+            'min_price'         => ! empty($minPrices) ? min($minPrices) : 0,
+            'regular_min_price' => ! empty($regularMinPrices) ? min($regularMinPrices) : 0,
+            'max_price'         => ! empty($maxPrices) ? max($maxPrices) : 0,
+            'regular_max_price' => ! empty($regularMaxPrices) ? max($regularMaxPrices) : 0,
+            'priceable_id'      => $this->product->id,
+            'priceable_type'    => get_class($this->product),
+            'channel_id'        => $this->channel->id,
+            'customer_group_id' => $this->customerGroup->id,
+        ];
+    }
+
+    /**
+     * Variant-level indices for the currently set variant.
+     */
+    public function getVariantIndices(): array
+    {
+        $variant = $this->variant;
+
+        $basePrice = (float) $variant->price;
+        $comparePrice = $variant->compare_price ? (float) $variant->compare_price : $basePrice;
+
+        $minPrice = $this->getVariantMinimalPrice($variant, $basePrice);
+
+        return [
+            'min_price'         => $minPrice,
+            'regular_min_price' => $comparePrice,
+            'max_price'         => $minPrice,
+            'regular_max_price' => $comparePrice,
+            'priceable_id'      => $variant->id,
+            'priceable_type'    => get_class($variant),
+            'channel_id'        => $this->channel->id,
+            'customer_group_id' => $this->customerGroup->id,
+        ];
+    }
+
+    /**
+     * Compute the minimal (best) price for a variant, considering:
+     * 1. Special price (time-bounded)
+     * 2. Catalog rule discount (from parent product's rules)
+     * 3. Customer group price
+     */
+    protected function getVariantMinimalPrice(ProductVariant $variant, float $basePrice): float
+    {
+        $prices = [$basePrice];
+
+        // 1. Special price
+        if ($variant->special_price && (float) $variant->special_price > 0) {
+            if (core()->isChannelDateInInterval($variant->special_price_from, $variant->special_price_to)) {
+                $prices[] = (float) $variant->special_price;
+            }
+        }
+
+        // 2. Catalog rule discount (applied to variant.price)
+        $catalogRulePrice = $this->getCatalogRuleDiscount($variant);
+        if ($catalogRulePrice !== null) {
+            $prices[] = $catalogRulePrice;
+        }
+
+        // 3. Customer group price
+        $customerGroupPrice = $this->getVariantCustomerGroupPrice($variant, $basePrice);
+        $prices[] = $customerGroupPrice;
+
+        return min($prices);
+    }
+
+    /**
+     * Compute catalog rule discount for a variant by reading the
+     * parent product's catalog_rule_products entries and applying
+     * the rule formula to the variant's own price.
+     */
+    protected function getCatalogRuleDiscount(ProductVariant $variant): ?float
+    {
+        $rules = $this->getParentCatalogRuleProducts();
+
+        if (empty($rules)) {
+            return null;
+        }
+
+        $today = Carbon::now()->format('Y-m-d');
+        $price = (float) $variant->price;
+        $applied = false;
+
+        foreach ($rules as $rule) {
+            if ($rule->channel_id != $this->channel->id) {
+                continue;
+            }
+
+            if ($rule->customer_group_id != $this->customerGroup->id) {
+                continue;
+            }
+
+            $startsFrom = $rule->starts_from ? Carbon::parse($rule->starts_from)->format('Y-m-d') : null;
+            $endsTill = $rule->ends_till ? Carbon::parse($rule->ends_till)->format('Y-m-d') : null;
+
+            if ($startsFrom && $today < $startsFrom) {
+                continue;
+            }
+
+            if ($endsTill && $today > $endsTill) {
+                continue;
+            }
+
+            $price = $this->calculateRulePrice($rule, $price);
+            $applied = true;
+
+            if ($rule->end_other_rules) {
+                break;
+            }
+        }
+
+        return $applied ? max(0, $price) : null;
+    }
+
+    /**
+     * Apply a single catalog rule's action to a price.
+     * Mirrors CatalogRuleProductPrice::calculate().
+     */
+    protected function calculateRulePrice(object $rule, float $price): float
+    {
+        return match ($rule->action_type) {
+            'to_fixed'   => min($rule->discount_amount, $price),
+            'to_percent' => $price * $rule->discount_amount / 100,
+            'by_fixed'   => max(0, $price - $rule->discount_amount),
+            'by_percent' => $price * (1 - $rule->discount_amount / 100),
+            default      => $price,
+        };
+    }
+
+    /**
+     * Get catalog_rule_products for the parent product (cached per indexer run).
+     */
+    protected function getParentCatalogRuleProducts(): array
+    {
+        if ($this->catalogRuleProductsCache === null) {
+            $this->catalogRuleProductsCache = DB::table('catalog_rule_products')
+                ->where('product_id', $this->product->id)
+                ->orderBy('sort_order')
+                ->orderBy('catalog_rule_id')
+                ->get()
+                ->all();
+        }
+
+        return $this->catalogRuleProductsCache;
+    }
+
+    /**
+     * Customer group price for a variant (mirrors AbstractType::getCustomerGroupPrice).
+     */
+    protected function getVariantCustomerGroupPrice(ProductVariant $variant, float $basePrice): float
+    {
+        $customerGroupPrices = $this->productCustomerGroupPriceRepository
+            ->prices($variant, $this->customerGroup->id);
+
+        if ($customerGroupPrices->isEmpty()) {
+            return $basePrice;
+        }
+
+        $lastQty = 1;
+        $lastPrice = $basePrice;
+
+        foreach ($customerGroupPrices as $cgp) {
+            if ($cgp->qty > 1 || $cgp->qty < $lastQty) {
+                continue;
+            }
+
+            if ($cgp->value_type == 'discount') {
+                if ($cgp->value >= 0 && $cgp->value <= 100) {
+                    $lastPrice = $basePrice - ($basePrice * $cgp->value) / 100;
+                    $lastQty = $cgp->qty;
+                }
+            } else {
+                if ($cgp->value >= 0 && $cgp->value < $lastPrice) {
+                    $lastPrice = $cgp->value;
+                    $lastQty = $cgp->qty;
+                }
+            }
+        }
+
+        return $lastPrice;
+    }
+
+    /**
+     * Get saleable variants from the parent product's loaded relation
+     * or query directly.
+     */
+    protected function getSaleableVariants(): \Illuminate\Support\Collection
+    {
+        if ($this->product->relationLoaded('flexibleVariants')) {
+            return $this->product->flexibleVariants->filter(fn ($v) => $v->isSaleable());
+        }
+
+        return ProductVariant::where('product_id', $this->product->id)
+            ->where('status', true)
+            ->where('quantity', '>', 0)
+            ->get();
+    }
+
+    /**
+     * Reset caches when switching products.
+     */
+    public function setProduct($product): static
+    {
+        $this->catalogRuleProductsCache = null;
+        $this->variant = null;
+
+        return parent::setProduct($product);
+    }
+}

+ 353 - 7
packages/Longyi/Core/src/Http/Controllers/Admin/FlexibleVariantController.php

@@ -4,12 +4,21 @@ namespace Longyi\Core\Http\Controllers\Admin;
 
 
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
+use Illuminate\Http\UploadedFile;
 use Illuminate\Routing\Controller;
 use Illuminate\Routing\Controller;
 use Illuminate\Foundation\Validation\ValidatesRequests;
 use Illuminate\Foundation\Validation\ValidatesRequests;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
+use Intervention\Image\ImageManager;
+use Longyi\Core\Helpers\FlexibleVariantOption;
+use Longyi\Core\Models\ProductOption;
+use Longyi\Core\Models\ProductOptionValue;
+use Longyi\Core\Models\ProductVariant;
 use Longyi\Core\Repositories\ProductOptionRepository;
 use Longyi\Core\Repositories\ProductOptionRepository;
 use Longyi\Core\Repositories\ProductOptionValueRepository;
 use Longyi\Core\Repositories\ProductOptionValueRepository;
 use Longyi\Core\Repositories\ProductVariantRepository;
 use Longyi\Core\Repositories\ProductVariantRepository;
-use Longyi\Core\Helpers\FlexibleVariantOption;
+use Webkul\Product\Helpers\Indexers\Price as PriceIndexer;
 use Webkul\Product\Repositories\ProductRepository;
 use Webkul\Product\Repositories\ProductRepository;
 
 
 class FlexibleVariantController extends Controller
 class FlexibleVariantController extends Controller
@@ -41,6 +50,12 @@ class FlexibleVariantController extends Controller
      */
      */
     protected $flexibleVariantHelper;
     protected $flexibleVariantHelper;
 
 
+
+    public $_product = null;
+
+
+    public $_selectedOptions = [];
+
     /**
     /**
      * Create a new controller instance.
      * Create a new controller instance.
      */
      */
@@ -56,6 +71,19 @@ class FlexibleVariantController extends Controller
         $this->productVariantRepository = $productVariantRepository;
         $this->productVariantRepository = $productVariantRepository;
         $this->productRepository = $productRepository;
         $this->productRepository = $productRepository;
         $this->flexibleVariantHelper = $flexibleVariantHelper;
         $this->flexibleVariantHelper = $flexibleVariantHelper;
+        //根据请求初始化产品信息
+        // $this->initProduct(request()->product_id ?? null);
+        
+
+    }
+    public function initProduct(int $productId){
+        if(!$productId){
+            $productId = request()->route('productId');
+        }
+        $this->_product = $this->productRepository->find($productId);
+        if (!$this->_product) {
+            throw new \Exception('Product not found');
+        }
     }
     }
 
 
     // ==================== OPTION MANAGEMENT ====================
     // ==================== OPTION MANAGEMENT ====================
@@ -99,8 +127,9 @@ class FlexibleVariantController extends Controller
     public function createOption(Request $request): JsonResponse
     public function createOption(Request $request): JsonResponse
     {
     {
         $this->validate($request, [
         $this->validate($request, [
+            'product_id' => 'required|exists:products,id',
             'label' => 'required|string|max:255',
             'label' => 'required|string|max:255',
-            'code' => 'required|string|max:100|unique:product_options,code',
+            'code' => 'required|string|max:100',
             'type' => 'required|in:select,radio,checkbox,color,button',
             'type' => 'required|in:select,radio,checkbox,color,button',
             'position' => 'nullable|integer',
             'position' => 'nullable|integer',
             'meta' => 'nullable|array',
             'meta' => 'nullable|array',
@@ -110,8 +139,20 @@ class FlexibleVariantController extends Controller
             'values.*.position' => 'nullable|integer',
             'values.*.position' => 'nullable|integer',
             'values.*.meta' => 'nullable|array',
             'values.*.meta' => 'nullable|array',
         ]);
         ]);
-
-        $option = $this->productOptionRepository->createWithValues($request->all());
+        $product = $this->productRepository->find($request->product_id);
+        if (!$product) {
+            return response()->json([
+                'success' => false,
+                'message' => 'Product not found',
+            ], 404);
+        }
+        $option = $this->productOptionRepository->createWithValues($request->except('product_id'));
+       
+        $product->options()->attach($option->id, [
+            'position' => $request->position ?? 0,
+            'is_required' => $request->is_required ?? false,
+            'meta' => isset($request->meta) ? json_encode($request->meta) : null,
+        ]);
 
 
         return response()->json([
         return response()->json([
             'success' => true,
             'success' => true,
@@ -176,7 +217,18 @@ class FlexibleVariantController extends Controller
      */
      */
     public function getProductOptions(int $productId): JsonResponse
     public function getProductOptions(int $productId): JsonResponse
     {
     {
-        $options = $this->flexibleVariantHelper->getProductOptions($productId);
+
+
+        $options=$this->flexibleVariantHelper->getAvailableOptions($productId);
+        // $this->initProduct($productId);
+        // $options = $this->_product->options()
+        //     ->with('values', function ($query) {
+        //         $query->whereHas('variants', function ($relation) {
+        //             // dd($relation->getModel()->getTable());exit;
+        //             $relation->whereIn($relation->getModel()->getTable().'.id', $this->_product->variants()->pluck('id'));
+        //         });
+        //     })->toSql();
+
 
 
         return response()->json([
         return response()->json([
             'success' => true,
             'success' => true,
@@ -190,6 +242,7 @@ class FlexibleVariantController extends Controller
     public function attachOptions(Request $request, int $productId): JsonResponse
     public function attachOptions(Request $request, int $productId): JsonResponse
     {
     {
         $this->validate($request, [
         $this->validate($request, [
+            'product_id' => 'required|exists:products,id',
             'options' => 'required|array',
             'options' => 'required|array',
             'options.*.id' => 'required|exists:product_options,id',
             'options.*.id' => 'required|exists:product_options,id',
             'options.*.position' => 'nullable|integer',
             'options.*.position' => 'nullable|integer',
@@ -237,13 +290,42 @@ class FlexibleVariantController extends Controller
             'data' => $variants,
             'data' => $variants,
         ]);
         ]);
     }
     }
+    public function storeSelectedOptions(){
+        foreach ($this->_selectedOptions as $optionIndex => $option) {
+            $optionModel = empty($option['id']) ? new ProductOption() : ProductOption::findOrFail($option['id']);
+
+            $mapOptionValues = $optionModel->values()->pluck('id','code')->toArray();
+
+            $optionModel->label = $option['label'];
+            $optionModel->type = $option['type'];
+            $optionModel->code = $option['code'];
+            $optionModel->position = $option['position'];
+            $optionModel->save();
+
+            $this->_selectedOptions[$optionIndex]['id'] = $optionModel->id;
+            $option['id'] = $optionModel->id;
+           
+            foreach ($option['option_values'] as $optionValueIndex => $value) {
+                $optionValueModel = ProductOptionValue::find(($value['id'] ?? null) ?: ($mapOptionValues[$value['code']] ?? null))
+                    ?? new ProductOptionValue(['product_option_id' => $option['id']]);
+
+                $optionValueModel->label = $value['label'];
+                $optionValueModel->code = $value['code'];
+                $optionValueModel->position = $value['position'];
+                $optionValueModel->save();
+
+                $this->_selectedOptions[$optionIndex]['option_values'][$optionValueIndex]['id'] =
+                    $optionValueModel->id;
+            }
 
 
+        }
+    }
     /**
     /**
      * Get single variant
      * Get single variant
      */
      */
     public function getVariant(int $id): JsonResponse
     public function getVariant(int $id): JsonResponse
     {
     {
-        $variant = $this->productVariantRepository->with('optionValues.option')->find($id);
+        $variant = $this->productVariantRepository->with('values.option')->find($id);
 
 
         if (!$variant) {
         if (!$variant) {
             return response()->json([
             return response()->json([
@@ -257,7 +339,171 @@ class FlexibleVariantController extends Controller
             'data' => $variant,
             'data' => $variant,
         ]);
         ]);
     }
     }
+    public function saveVariants(int $productId,Request $request){
+        $this->initProduct($productId);
+        $this->validate($request,[
+            'selected_options' => 'array',
+            'selected_options.*.code' => 'required|string',
+            'selected_options.*.option_values.*.code' => 'required|string',
+            'variants' => 'array',
+            'variants.*.sku' => 'required|string',
+            'variants.*.quantity' => 'required|integer',
+            'variants.*.values' => 'array',
+            'variants.*.values.*.code' => 'required|string',
+            'variants.*.image_ids' => 'array',
+            'variants.*.image_ids.*' => 'integer|exists:product_images,id',
+        ]);
+
+
+
+        DB::beginTransaction();
+        $this->_selectedOptions = $request->selected_options;
+
+        //存储选项
+        $this->storeSelectedOptions();
+
+        $variants=$request->variants;
+        //如果无变体,则删除所有变体和选项
+        if (!count($variants)) {
+            $variant=$this->_product->variants()->first();
+            $variant->values()->detach();
+
+            //删除产品选项
+            $this->_product->options()->delete();
+            $this->_product->flexibleVariants()
+                ->where('id', '!=', $variant->id)
+                ->get()
+                ->each(
+                    fn ($variant) => $variant->delete()
+                );
+            DB::commit();
+            return response()->json([
+                'success' => true,
+                'message' => 'Variants deleted successfully',
+            ]);
+
+        }
+        //存储变体
+        foreach ($variants as $variantIndex => $variantData) {
+            if (! empty($variantData['id'])) {
+                $variant = ProductVariant::find($variantData['id']);
+            } elseif (! empty($variantData['copied_id'])) {
+                $copiedVariant = ProductVariant::find($variantData['copied_id']);
+                $variant = $copiedVariant->replicate();
+                $variant->save();
+            } else {
+                $variant = ProductVariant::onlyTrashed()
+                    ->where('product_id', $this->_product->id)
+                    ->where('sku', $variantData['sku'])
+                    ->first();
+
+                if ($variant) {
+                    $variant->restore();
+                } else {
+                    $variant = new ProductVariant([
+                        'product_id' => $this->_product->id,
+                    ]);
+                }
+            }
+
+            $variant->sku = $variantData['sku'];
+            $variant->quantity = $variantData['quantity'];
+            $variant->price = $variantData['price'];
+            $variant->save();
+
+            $this->syncVariantBasePrices($variant, (float) $variantData['price']);
+
+            $optionsValues = $this->mapOptionValuesToIds($variantData['values']);
+
+            $variant->values()->sync($optionsValues);
+
+            if (isset($variantData['image_ids'])) {
+                $imageSync = collect($variantData['image_ids'])
+                    ->values()
+                    ->mapWithKeys(fn ($imageId, $i) => [$imageId => ['position' => $i]]);
+                $variant->images()->sync($imageSync);
+            }
+
+            $variants[$variantIndex]['variant_id'] = $variant->id;
+        }
+        
+        $productOptions = collect($this->_selectedOptions)
+            ->mapWithKeys(function ($option) use ($variant) {
+                return [
+                    $option['id'] => [
+                        'position' => $option['position'],
+                        ],
+                    ];
+            });
+        //同步选项
+        $this->_product->options()->sync($productOptions);
+        
+        $variantIds=collect($variants)->pluck('id');
+        //删除无用的变体
+        $this->_product->flexibleVariants()->whereNotIn('id', $variantIds)->delete();
+        DB::commit();
+
+        $this->reindexProduct($productId);
+        return response()->json([
+            'success' => true,
+            'message' => 'Variants saved successfully',
+            'data' => [
+                'variants' => $variants,
+                'selected_options' => $this->_selectedOptions,
+            ],
+        ]);
+        
+
+    }
+    /**
+     * Create or update base price indices (customer_group_id = NULL) for a variant across all channels.
+     */
+    protected function syncVariantBasePrices(ProductVariant $variant, float $price): void
+    {
+        foreach (core()->getAllChannels() as $channel) {
+            $basePrice = $variant->basePrices()
+                ->where('channel_id', $channel->id)
+                ->first();
+
+            $priceData = [
+                'min_price'         => $price,
+                'regular_min_price' => $price,
+                'max_price'         => $price,
+                'regular_max_price' => $price,
+            ];
+
+            if ($basePrice) {
+                $basePrice->update($priceData);
+            } else {
+                $variant->price_indices()->create(array_merge($priceData, [
+                    'channel_id'        => $channel->id,
+                    'customer_group_id' => null,
+                ]));
+            }
+        }
+    }
+
+    public function mapOptionValuesToIds(array $values){
+        $valueIds = [];
+        foreach ($values as $optionCode => $value) {
+            $selectedOption = collect($this->_selectedOptions)
+                ->first(fn ($o) => $o['code'] == $optionCode);
+
+            if (! $selectedOption) {
+                throw new \InvalidArgumentException("Option [{$optionCode}] not found in selected options.");
+            }
+
+            $optionValue = collect($selectedOption['option_values'])
+                ->first(fn ($v) => $v['code'] == $value['code']);
+
+            if (! $optionValue) {
+                throw new \InvalidArgumentException("Option value [{$value['code']}] not found in option [{$optionCode}].");
+            }
 
 
+            $valueIds[] = $optionValue['id'];
+        }
+        return $valueIds;
+    }
     /**
     /**
      * Create new variant
      * Create new variant
      */
      */
@@ -345,8 +591,12 @@ class FlexibleVariantController extends Controller
             ], 404);
             ], 404);
         }
         }
 
 
+        $productId = $variant->product_id;
+
         $this->productVariantRepository->delete($id);
         $this->productVariantRepository->delete($id);
 
 
+        $this->reindexProduct($productId);
+
         return response()->json([
         return response()->json([
             'success' => true,
             'success' => true,
             'message' => 'Variant deleted successfully',
             'message' => 'Variant deleted successfully',
@@ -387,10 +637,18 @@ class FlexibleVariantController extends Controller
             'status' => 'required|boolean',
             'status' => 'required|boolean',
         ]);
         ]);
 
 
+        $productIds = [];
+
         foreach ($request->variant_ids as $variantId) {
         foreach ($request->variant_ids as $variantId) {
-            $this->productVariantRepository->update([
+            $variant = $this->productVariantRepository->update([
                 'status' => $request->status,
                 'status' => $request->status,
             ], $variantId);
             ], $variantId);
+
+            $productIds[$variant->product_id] = true;
+        }
+
+        foreach (array_keys($productIds) as $productId) {
+            $this->reindexProduct($productId);
         }
         }
 
 
         return response()->json([
         return response()->json([
@@ -398,4 +656,92 @@ class FlexibleVariantController extends Controller
             'message' => 'Status updated successfully',
             'message' => 'Status updated successfully',
         ]);
         ]);
     }
     }
+
+    /**
+     * Sync images for a variant.
+     *
+     * Accepts a mix of existing product_image IDs and new file uploads.
+     * New files are stored into the product gallery first, then all IDs
+     * are synced to the variant via the pivot table.
+     *
+     * POST /variants/{id}/images
+     * Body (multipart/form-data):
+     *   image_ids[]   - existing product_image IDs to keep/attach
+     *   uploads[]     - new image files to upload
+     */
+    public function syncVariantImages(int $id, Request $request): JsonResponse
+    {
+        $this->validate($request, [
+            'image_ids'   => 'array',
+            'image_ids.*' => 'integer|exists:product_images,id',
+            'uploads'     => 'array',
+            'uploads.*'   => 'image|max:5120',
+        ]);
+
+        $variant = ProductVariant::findOrFail($id);
+        $product = $variant->product;
+
+        $imageIds = collect($request->input('image_ids', []));
+
+        if ($request->hasFile('uploads')) {
+            foreach ($request->file('uploads') as $file) {
+                $newImage = $this->storeProductImage($file, $product);
+                $imageIds->push($newImage->id);
+            }
+        }
+
+        $syncData = $imageIds->values()->mapWithKeys(
+            fn ($imageId, $i) => [$imageId => ['position' => $i]]
+        );
+
+        $variant->images()->sync($syncData);
+
+        return response()->json([
+            'success' => true,
+            'data'    => $variant->load('images')->images,
+        ]);
+    }
+
+    /**
+     * Store an uploaded file into the product gallery (product_images table).
+     */
+    protected function storeProductImage(UploadedFile $file, $product): \Webkul\Product\Models\ProductImage
+    {
+        $directory = 'product/' . $product->id;
+
+        if (Str::contains($file->getMimeType(), 'image')) {
+            $manager = new ImageManager;
+            $image = $manager->make($file)->encode('webp');
+            $path = $directory . '/' . Str::random(40) . '.webp';
+            Storage::put($path, $image);
+        } else {
+            $path = $file->store($directory);
+        }
+
+        return $product->images()->create([
+            'type'     => 'images',
+            'path'     => $path,
+            'position' => $product->images()->count(),
+        ]);
+    }
+
+    /**
+     * Trigger price reindex for a product and its flexible variants.
+     */
+    protected function reindexProduct(int $productId): void
+    {
+        $product = $this->productRepository->with([
+            'attribute_family',
+            'attribute_values',
+            'price_indices',
+            'customer_group_prices',
+            'flexibleVariants',
+            'flexibleVariants.price_indices',
+            'flexibleVariants.customer_group_prices',
+        ])->find($productId);
+
+        if ($product) {
+            app(PriceIndexer::class)->reindexBatch([$product]);
+        }
+    }
 }
 }

+ 8 - 0
packages/Longyi/Core/src/Models/ProductOption.php

@@ -8,6 +8,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Longyi\Core\Contracts\ProductOption as ProductOptionContract;
 use Longyi\Core\Contracts\ProductOption as ProductOptionContract;
 use Webkul\Product\Models\Product;
 use Webkul\Product\Models\Product;
 
 
+/**
+ * @property int    $id
+ * @property string $label
+ * @property string $type
+ * @property string $code
+ * @property int    $position
+ * @property array  $meta
+ */
 class ProductOption extends Model implements ProductOptionContract
 class ProductOption extends Model implements ProductOptionContract
 {
 {
     /**
     /**

+ 8 - 1
packages/Longyi/Core/src/Models/ProductOptionValue.php

@@ -6,7 +6,14 @@ use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Longyi\Core\Contracts\ProductOptionValue as ProductOptionValueContract;
 use Longyi\Core\Contracts\ProductOptionValue as ProductOptionValueContract;
-
+/**
+ * @property int    $id
+ * @property int    $product_option_id
+ * @property string $label
+ * @property string $code
+ * @property int    $position
+ * @property array  $meta
+ */
 class ProductOptionValue extends Model implements ProductOptionValueContract
 class ProductOptionValue extends Model implements ProductOptionValueContract
 {
 {
     /**
     /**

+ 77 - 19
packages/Longyi/Core/src/Models/ProductVariant.php

@@ -5,10 +5,27 @@ namespace Longyi\Core\Models;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
+use Illuminate\Database\Eloquent\Relations\MorphMany;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Illuminate\Database\Eloquent\SoftDeletes;
 use Longyi\Core\Contracts\ProductVariant as ProductVariantContract;
 use Longyi\Core\Contracts\ProductVariant as ProductVariantContract;
 use Webkul\Product\Models\Product;
 use Webkul\Product\Models\Product;
-
+use Webkul\Product\Models\ProductCustomerGroupPriceProxy;
+use Webkul\Product\Models\ProductImageProxy;
+use Webkul\Product\Models\ProductPriceIndexProxy;
+
+/**
+ * @property int    $id
+ * @property int    $product_id
+ * @property string $sku
+ * @property string $name
+ * @property float  $price
+ * @property float  $cost
+ * @property float  $weight
+ * @property int    $quantity
+ * @property boolean $status
+ * @property array  $images
+ * @property int    $sort_order
+ */
 class ProductVariant extends Model implements ProductVariantContract
 class ProductVariant extends Model implements ProductVariantContract
 {
 {
     use SoftDeletes;
     use SoftDeletes;
@@ -38,7 +55,6 @@ class ProductVariant extends Model implements ProductVariantContract
         'weight',
         'weight',
         'quantity',
         'quantity',
         'status',
         'status',
-        'images',
         'sort_order',
         'sort_order',
     ];
     ];
 
 
@@ -57,7 +73,6 @@ class ProductVariant extends Model implements ProductVariantContract
         'quantity' => 'integer',
         'quantity' => 'integer',
         'status' => 'boolean',
         'status' => 'boolean',
         'sort_order' => 'integer',
         'sort_order' => 'integer',
-        'images' => 'array',
         'special_price_from' => 'date',
         'special_price_from' => 'date',
         'special_price_to' => 'date',
         'special_price_to' => 'date',
         'deleted_at' => 'datetime',
         'deleted_at' => 'datetime',
@@ -72,7 +87,8 @@ class ProductVariant extends Model implements ProductVariantContract
 
 
         // When deleting a variant, detach all option values
         // When deleting a variant, detach all option values
         static::deleting(function ($variant) {
         static::deleting(function ($variant) {
-            $variant->optionValues()->detach();
+            $variant->values()->detach();
+            $variant->images()->detach();
         });
         });
     }
     }
 
 
@@ -84,10 +100,30 @@ class ProductVariant extends Model implements ProductVariantContract
         return $this->belongsTo(Product::class, 'product_id');
         return $this->belongsTo(Product::class, 'product_id');
     }
     }
 
 
+    /**
+     * Get the price indices for this variant.
+     */
+    public function price_indices(): MorphMany
+    {
+        return $this->morphMany(ProductPriceIndexProxy::modelClass(), 'priceable');
+    }
+    public function basePrices(): MorphMany
+    {
+        return $this->price_indices()->whereNull('customer_group_id');
+    }
+
+    /**
+     * Get the customer group prices for this variant.
+     */
+    public function customer_group_prices(): MorphMany
+    {
+        return $this->morphMany(ProductCustomerGroupPriceProxy::modelClass(), 'priceable');
+    }
+
     /**
     /**
      * Get all option values for this variant (many-to-many)
      * Get all option values for this variant (many-to-many)
      */
      */
-    public function optionValues(): BelongsToMany
+    public function values(): BelongsToMany
     {
     {
         return $this->belongsToMany(
         return $this->belongsToMany(
             ProductOptionValue::class,
             ProductOptionValue::class,
@@ -106,11 +142,35 @@ class ProductVariant extends Model implements ProductVariantContract
     }
     }
 
 
     /**
     /**
-     * Get the effective price (considering special price)
+     * Get the effective price for the current customer group / channel
+     * from the pre-computed price index. Falls back to variant.price
+     * when no index exists yet (e.g. before the first reindex).
+     */
+    public function getEffectivePrice(?int $customerGroupId = null, ?int $channelId = null): float
+    {
+        $customerGroupId ??= app(\Webkul\Customer\Repositories\CustomerRepository::class)
+            ->getCurrentGroup()->id;
+
+        $channelId ??= core()->getCurrentChannel()->id;
+
+        $index = $this->price_indices
+            ->where('customer_group_id', $customerGroupId)
+            ->where('channel_id', $channelId)
+            ->first();
+
+        if ($index) {
+            return (float) $index->min_price;
+        }
+
+        return $this->getBasicEffectivePrice();
+    }
+
+    /**
+     * Basic effective price calculation without price index
+     * (considers only special_price on the variant itself).
      */
      */
-    public function getEffectivePrice(): float
+    public function getBasicEffectivePrice(): float
     {
     {
-        // Check if special price is active
         if ($this->special_price) {
         if ($this->special_price) {
             $now = now();
             $now = now();
             $isActive = true;
             $isActive = true;
@@ -132,19 +192,17 @@ class ProductVariant extends Model implements ProductVariantContract
     }
     }
 
 
     /**
     /**
-     * Get images as array
+     * Get the images for this variant (many-to-many with product_images).
      */
      */
-    public function getImagesAttribute($value)
+    public function images(): BelongsToMany
     {
     {
-        return $value ? json_decode($value, true) : [];
-    }
-
-    /**
-     * Set images from array
-     */
-    public function setImagesAttribute($value)
-    {
-        $this->attributes['images'] = $value ? json_encode($value) : null;
+        return $this->belongsToMany(
+            ProductImageProxy::modelClass(),
+            'product_variant_images',
+            'product_variant_id',
+            'product_image_id'
+        )->withPivot('position')
+         ->orderByPivot('position');
     }
     }
 
 
     /**
     /**

+ 3 - 0
packages/Longyi/Core/src/Providers/LongyiCoreServiceProvider.php

@@ -2,6 +2,7 @@
 
 
 namespace Longyi\Core\Providers;
 namespace Longyi\Core\Providers;
 
 
+use Illuminate\Support\Facades\Blade;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\ServiceProvider;
 use Illuminate\Support\Facades\Route;
 use Illuminate\Support\Facades\Route;
 
 
@@ -18,6 +19,8 @@ class LongyiCoreServiceProvider extends ServiceProvider
 
 
         $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'longyi');
         $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'longyi');
 
 
+        Blade::anonymousComponentPath(__DIR__.'/../Resources/views/components', 'longyi');
+
         $this->registerRoutes();
         $this->registerRoutes();
 
 
         $this->publishes([
         $this->publishes([

+ 31 - 9
packages/Longyi/Core/src/Repositories/ProductVariantRepository.php

@@ -3,8 +3,9 @@
 namespace Longyi\Core\Repositories;
 namespace Longyi\Core\Repositories;
 
 
 use Webkul\Core\Eloquent\Repository;
 use Webkul\Core\Eloquent\Repository;
+use Webkul\Product\Helpers\Indexers\Price as PriceIndexer;
+use Webkul\Product\Repositories\ProductRepository;
 use Longyi\Core\Models\ProductVariant;
 use Longyi\Core\Models\ProductVariant;
-use Illuminate\Support\Collection;
 
 
 class ProductVariantRepository extends Repository
 class ProductVariantRepository extends Repository
 {
 {
@@ -29,7 +30,7 @@ class ProductVariantRepository extends Repository
     {
     {
         $query = $this->model
         $query = $this->model
             ->where('product_id', $productId)
             ->where('product_id', $productId)
-            ->with('optionValues.option')
+            ->with('values.option')
             ->orderBy('sort_order');
             ->orderBy('sort_order');
 
 
         if ($onlyActive) {
         if ($onlyActive) {
@@ -51,7 +52,7 @@ class ProductVariantRepository extends Repository
             ->where('product_id', $productId)
             ->where('product_id', $productId)
             ->where('status', true)
             ->where('status', true)
             ->where('quantity', '>', 0)
             ->where('quantity', '>', 0)
-            ->with('optionValues.option')
+            ->with('values.option')
             ->orderBy('sort_order')
             ->orderBy('sort_order')
             ->get();
             ->get();
     }
     }
@@ -81,10 +82,12 @@ class ProductVariantRepository extends Repository
         $variant = $this->create($data);
         $variant = $this->create($data);
 
 
         if (!empty($optionValueIds)) {
         if (!empty($optionValueIds)) {
-            $variant->optionValues()->attach($optionValueIds);
+            $variant->values()->attach($optionValueIds);
         }
         }
 
 
-        return $variant->fresh('optionValues.option');
+        $this->reindexProductPrices($variant->product_id);
+
+        return $variant->fresh('values.option');
     }
     }
 
 
     /**
     /**
@@ -104,10 +107,12 @@ class ProductVariantRepository extends Repository
         $variant->update($data);
         $variant->update($data);
 
 
         if ($optionValueIds !== null) {
         if ($optionValueIds !== null) {
-            $variant->optionValues()->sync($optionValueIds);
+            $variant->values()->sync($optionValueIds);
         }
         }
 
 
-        return $variant->fresh('optionValues.option');
+        $this->reindexProductPrices($variant->product_id);
+
+        return $variant->fresh('values.option');
     }
     }
 
 
     /**
     /**
@@ -126,12 +131,12 @@ class ProductVariantRepository extends Repository
         // Get all variants for the product
         // Get all variants for the product
         $variants = $this->model
         $variants = $this->model
             ->where('product_id', $productId)
             ->where('product_id', $productId)
-            ->with('optionValues')
+            ->with('values')
             ->get();
             ->get();
 
 
         // Find variant with exact option value match
         // Find variant with exact option value match
         foreach ($variants as $variant) {
         foreach ($variants as $variant) {
-            $variantOptionValueIds = $variant->optionValues->pluck('id')->sort()->values()->toArray();
+            $variantOptionValueIds = $variant->values->pluck('id')->sort()->values()->toArray();
 
 
             if ($variantOptionValueIds === $optionValueIds && count($variantOptionValueIds) === $count) {
             if ($variantOptionValueIds === $optionValueIds && count($variantOptionValueIds) === $count) {
                 return $variant;
                 return $variant;
@@ -173,4 +178,21 @@ class ProductVariantRepository extends Repository
         $variant->increment('quantity', $quantity);
         $variant->increment('quantity', $quantity);
         return true;
         return true;
     }
     }
+
+    /**
+     * Trigger price reindex for the parent product (and its variants).
+     */
+    protected function reindexProductPrices(int $productId): void
+    {
+        $product = app(ProductRepository::class)->with([
+            'attribute_family',
+            'attribute_values',
+            'price_indices',
+            'customer_group_prices',
+        ])->find($productId);
+
+        if ($product) {
+            app(PriceIndexer::class)->reindexBatch([$product]);
+        }
+    }
 }
 }

+ 2 - 0
packages/Longyi/Core/src/Resources/lang/en/app.php

@@ -17,6 +17,7 @@ return [
             'is-required' => 'Required',
             'is-required' => 'Required',
             'meta' => 'Meta Data',
             'meta' => 'Meta Data',
             'values' => 'Option Values',
             'values' => 'Option Values',
+            'empty' => 'No options created yet',
             
             
             'types' => [
             'types' => [
                 'select' => 'Select Dropdown',
                 'select' => 'Select Dropdown',
@@ -59,6 +60,7 @@ return [
             'option-values' => 'Option Values',
             'option-values' => 'Option Values',
             'saleable' => 'Saleable',
             'saleable' => 'Saleable',
             'not-saleable' => 'Not Saleable',
             'not-saleable' => 'Not Saleable',
+            'empty' => 'No variants created yet',
             
             
             'bulk' => [
             'bulk' => [
                 'update-quantities' => 'Update Quantities',
                 'update-quantities' => 'Update Quantities',

+ 0 - 268
packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible-variant.blade.php

@@ -1,268 +0,0 @@
-@push('scripts')
-    <script type="text/x-template" id="flexible-variant-template">
-        <div class="flexible-variant-container">
-            <!-- Options Section -->
-            <div class="panel">
-                <div class="panel-header">
-                    <h3>@{{ "@lang('longyi::app.flexible-variant.options.title')" }}</h3>
-                    <button 
-                        type="button" 
-                        class="btn btn-primary btn-sm"
-                        @click="showOptionModal = true"
-                    >
-                        @{{ "@lang('longyi::app.flexible-variant.options.create')" }}
-                    </button>
-                </div>
-
-                <div class="panel-body">
-                    <div v-if="productOptions.length === 0" class="empty-state">
-                        <p>@{{ "@lang('longyi::app.flexible-variant.options.title')" }}</p>
-                    </div>
-
-                    <table v-else class="table">
-                        <thead>
-                            <tr>
-                                <th>@{{ "@lang('longyi::app.flexible-variant.options.label')" }}</th>
-                                <th>@{{ "@lang('longyi::app.flexible-variant.options.type')" }}</th>
-                                <th>@{{ "@lang('longyi::app.flexible-variant.options.values')" }}</th>
-                                <th>@{{ "@lang('longyi::app.flexible-variant.options.is-required')" }}</th>
-                                <th>Actions</th>
-                            </tr>
-                        </thead>
-                        <tbody>
-                            <tr v-for="option in productOptions" :key="option.id">
-                                <td>@{{ option.label }}</td>
-                                <td>@{{ option.type }}</td>
-                                <td>@{{ option.values.length }} values</td>
-                                <td>
-                                    <span v-if="option.pivot.is_required" class="badge badge-success">Yes</span>
-                                    <span v-else class="badge badge-secondary">No</span>
-                                </td>
-                                <td>
-                                    <button @click="editOption(option)" class="btn btn-sm btn-info">Edit</button>
-                                    <button @click="removeOption(option.id)" class="btn btn-sm btn-danger">Remove</button>
-                                </td>
-                            </tr>
-                        </tbody>
-                    </table>
-                </div>
-            </div>
-
-            <!-- Variants Section -->
-            <div class="panel mt-4">
-                <div class="panel-header">
-                    <h3>@{{ "@lang('longyi::app.flexible-variant.variants.title')" }}</h3>
-                    <button 
-                        type="button" 
-                        class="btn btn-primary btn-sm"
-                        @click="showVariantModal = true"
-                        :disabled="productOptions.length === 0"
-                    >
-                        @{{ "@lang('longyi::app.flexible-variant.variants.create')" }}
-                    </button>
-                </div>
-
-                <div class="panel-body">
-                    <div v-if="variants.length === 0" class="empty-state">
-                        <p>No variants created yet</p>
-                    </div>
-
-                    <table v-else class="table">
-                        <thead>
-                            <tr>
-                                <th>
-                                    <input type="checkbox" @change="toggleAllVariants" v-model="allVariantsSelected">
-                                </th>
-                                <th>@{{ "@lang('longyi::app.flexible-variant.variants.sku')" }}</th>
-                                <th>@{{ "@lang('longyi::app.flexible-variant.variants.name')" }}</th>
-                                <th>@{{ "@lang('longyi::app.flexible-variant.variants.price')" }}</th>
-                                <th>@{{ "@lang('longyi::app.flexible-variant.variants.quantity')" }}</th>
-                                <th>@{{ "@lang('longyi::app.flexible-variant.variants.status')" }}</th>
-                                <th>Actions</th>
-                            </tr>
-                        </thead>
-                        <tbody>
-                            <tr v-for="variant in variants" :key="variant.id">
-                                <td>
-                                    <input type="checkbox" v-model="selectedVariants" :value="variant.id">
-                                </td>
-                                <td>@{{ variant.sku }}</td>
-                                <td>@{{ variant.name || '-' }}</td>
-                                <td>@{{ formatPrice(variant.price) }}</td>
-                                <td>
-                                    <input 
-                                        type="number" 
-                                        v-model.number="variant.quantity" 
-                                        @change="updateVariantQuantity(variant)"
-                                        class="form-control form-control-sm"
-                                        style="width: 80px;"
-                                    >
-                                </td>
-                                <td>
-                                    <span v-if="variant.status" class="badge badge-success">Active</span>
-                                    <span v-else class="badge badge-secondary">Inactive</span>
-                                </td>
-                                <td>
-                                    <button @click="editVariant(variant)" class="btn btn-sm btn-info">Edit</button>
-                                    <button @click="deleteVariant(variant.id)" class="btn btn-sm btn-danger">Delete</button>
-                                </td>
-                            </tr>
-                        </tbody>
-                    </table>
-
-                    <!-- Bulk Actions -->
-                    <div v-if="selectedVariants.length > 0" class="bulk-actions mt-3">
-                        <button @click="bulkEnableVariants" class="btn btn-sm btn-success">
-                            @{{ "@lang('longyi::app.flexible-variant.variants.bulk.enable')" }}
-                        </button>
-                        <button @click="bulkDisableVariants" class="btn btn-sm btn-warning">
-                            @{{ "@lang('longyi::app.flexible-variant.variants.bulk.disable')" }}
-                        </button>
-                    </div>
-                </div>
-            </div>
-
-            <!-- Option Modal (Simplified - would need full implementation) -->
-            <div v-if="showOptionModal" class="modal">
-                <!-- Modal content for adding/editing options -->
-            </div>
-
-            <!-- Variant Modal (Simplified - would need full implementation) -->
-            <div v-if="showVariantModal" class="modal">
-                <!-- Modal content for adding/editing variants -->
-            </div>
-        </div>
-    </script>
-
-    <script type="text/javascript">
-        Vue.component('flexible-variant', {
-            template: '#flexible-variant-template',
-
-            props: ['productId'],
-
-            data() {
-                return {
-                    productOptions: [],
-                    variants: [],
-                    selectedVariants: [],
-                    allVariantsSelected: false,
-                    showOptionModal: false,
-                    showVariantModal: false,
-                    loading: false,
-                };
-            },
-
-            mounted() {
-                if (this.productId) {
-                    this.loadProductOptions();
-                    this.loadVariants();
-                }
-            },
-
-            methods: {
-                async loadProductOptions() {
-                    try {
-                        const response = await axios.get(`/admin/flexible-variant/products/${this.productId}/options`);
-                        this.productOptions = response.data.data;
-                    } catch (error) {
-                        console.error('Failed to load options:', error);
-                    }
-                },
-
-                async loadVariants() {
-                    try {
-                        const response = await axios.get(`/admin/flexible-variant/products/${this.productId}/variants`);
-                        this.variants = response.data.data;
-                    } catch (error) {
-                        console.error('Failed to load variants:', error);
-                    }
-                },
-
-                async updateVariantQuantity(variant) {
-                    try {
-                        await axios.put(`/admin/flexible-variant/variants/${variant.id}`, {
-                            quantity: variant.quantity
-                        });
-                        this.$toastr.success('Quantity updated successfully');
-                    } catch (error) {
-                        this.$toastr.error('Failed to update quantity');
-                    }
-                },
-
-                async deleteVariant(variantId) {
-                    if (!confirm('Are you sure you want to delete this variant?')) {
-                        return;
-                    }
-
-                    try {
-                        await axios.delete(`/admin/flexible-variant/variants/${variantId}`);
-                        this.loadVariants();
-                        this.$toastr.success('Variant deleted successfully');
-                    } catch (error) {
-                        this.$toastr.error('Failed to delete variant');
-                    }
-                },
-
-                async bulkEnableVariants() {
-                    try {
-                        await axios.post('/admin/flexible-variant/variants/bulk/status', {
-                            variant_ids: this.selectedVariants,
-                            status: true
-                        });
-                        this.loadVariants();
-                        this.selectedVariants = [];
-                        this.$toastr.success('Variants enabled successfully');
-                    } catch (error) {
-                        this.$toastr.error('Failed to enable variants');
-                    }
-                },
-
-                async bulkDisableVariants() {
-                    try {
-                        await axios.post('/admin/flexible-variant/variants/bulk/status', {
-                            variant_ids: this.selectedVariants,
-                            status: false
-                        });
-                        this.loadVariants();
-                        this.selectedVariants = [];
-                        this.$toastr.success('Variants disabled successfully');
-                    } catch (error) {
-                        this.$toastr.error('Failed to disable variants');
-                    }
-                },
-
-                toggleAllVariants() {
-                    if (this.allVariantsSelected) {
-                        this.selectedVariants = this.variants.map(v => v.id);
-                    } else {
-                        this.selectedVariants = [];
-                    }
-                },
-
-                formatPrice(price) {
-                    return new Intl.NumberFormat('en-US', {
-                        style: 'currency',
-                        currency: 'USD'
-                    }).format(price);
-                },
-
-                editOption(option) {
-                    // TODO: Implement option editing
-                    console.log('Edit option:', option);
-                },
-
-                removeOption(optionId) {
-                    // TODO: Implement option removal
-                    console.log('Remove option:', optionId);
-                },
-
-                editVariant(variant) {
-                    // TODO: Implement variant editing
-                    console.log('Edit variant:', variant);
-                },
-            }
-        });
-    </script>
-@endpush
-
-<flexible-variant product-id="{{ $product->id ?? '' }}"></flexible-variant>

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1004 - 0
packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible_variant.blade.php


+ 144 - 0
packages/Longyi/Core/src/Resources/views/admin/catalog/products/edit/types/flexible_variant/edit.blade.php

@@ -0,0 +1,144 @@
+<script type="text/x-template" id="flexiblevariant-eidtdialog-template">
+    <el-dialog title="Variant Edit" width="500" class="flexiblevariant-eidtdialog"
+        :model-value="isShow" @update:model-value="$emit('update:isShow', $event)"
+    >
+        <el-form :model="form" ref="variantFormRef">
+            <el-form-item label="name">
+                <el-input v-model="form.name" autocomplete="off" />
+            </el-form-item>
+            <el-form-item label="SKU"
+                prop="sku"
+                :rules="[{ required: true,  message: 'sku is required', trigger: 'blur',}]"
+            >
+                <el-input v-model="form.sku" autocomplete="off" />
+            </el-form-item>
+            <el-form-item label="price"
+                prop="price"
+                :rules="[
+                    { required: true, message: 'price is required', trigger: 'blur' },
+                    { pattern: /^(0|[1-9]\d*)(\.\d{1,4})?$/, message: 'Price must be a valid decimal number.', trigger: 'blur' }
+                ]"
+            >
+                <el-input v-model="form.price" autocomplete="off" />
+            </el-form-item>
+            <el-form-item label="quantity"
+                prop="quantity"
+                :rules="[{ required: true,  message: 'quantity is required', trigger: 'blur',}]"
+            >
+                <el-input v-model="form.quantity" autocomplete="off" />
+            </el-form-item>
+            <el-form-item label="weight">
+                <el-input v-model="form.weight" autocomplete="off" />
+            </el-form-item>
+        </el-form>
+        <template #footer>
+            <div class="dialog-footer">
+                <el-button @click="cancelHandler">Cancel</el-button>
+                <el-button type="primary" @click="saveVariant">
+                Confirm
+                </el-button>
+            </div>
+        </template>
+    </el-dialog>
+</script>
+<script type="module">
+
+        app.component('flexiblevariant-eidtdialog', {
+            template: '#flexiblevariant-eidtdialog-template',
+
+            props: {
+                variantId: Number,
+                isShow: {
+                    type: Boolean,
+                    default: false
+                }
+                
+            },
+            emits: ['update:isShow','confirm'], // 显式声明事件(推荐)
+            data() {
+                return {
+                    form: {
+                        sku: '',
+                        name: '',
+                        price: 0,
+                        weight: 0,
+                        quantity: 0,
+                        // status: false,
+                    },
+    
+                };
+            },
+            watch: {
+                variantId:{
+                    handler(newVal, oldVal, onCleanup) {
+                        const controller = new AbortController();
+                        
+                        this.getVariant(newVal);
+
+                        onCleanup(() => {
+                            // 终止过期请求
+                            controller.abort();
+                        });
+                    
+                    },
+                    flush: 'post', // 不然$loading无法添加到flexiblevariant-eidtdialog上
+                }
+            },
+
+            mounted() {
+                
+            },
+
+            methods: {
+               
+                getVariant(id) {
+                    let loading = this.$loading({
+                        target: '.flexiblevariant-eidtdialog'
+                    });
+                    axios.get(`/admin/flexible-variant/variants/${id}`).then((result) => {
+                        console.log(result);
+
+                        this.form = result.data.data;
+                        loading.close();
+                    }).catch((error) => {
+                        this.$message({
+                            message: error.message,
+                            type: 'error'
+                        });
+                        loading.close();
+                    });
+                },
+                async saveVariant() {
+                    // /admin/flexible-variant/variants/{id}
+                    await this.$refs.variantFormRef.validate();
+                    let requestData = {
+                        sku: this.form.sku,
+                        name: this.form.name,
+                        price: this.form.price,
+                        weight: this.form.weight,
+                        quantity: this.form.quantity,
+                    }
+                    try {
+                        let res = await axios.put(`/admin/flexible-variant/variants/${this.variantId}`,requestData);
+                        this.$message({
+                            message: 'Save successfully.',
+                            type: 'success',
+                        });
+                        this.$emit('update:isShow', false);
+                        this.$emit('confirm', res.data.data);
+                    } catch(err) {
+                        this.$message({
+                            message: err.message,
+                            type: 'error',
+                        });
+                    }
+                    
+                },
+                cancelHandler() {
+                    this.$emit('update:isShow', false);
+                    this.$emit('cancel', {});
+                }
+            },
+
+        });
+    </script>

+ 31 - 0
packages/Longyi/Core/src/Resources/views/components/admin/nesteddraggable.blade.php

@@ -0,0 +1,31 @@
+<!--这里的代码只是备份例子,不可使用,如要使用需要参考下面代码再开发-->
+    <script type="text/x-template" id="nested-draggable-template">
+        <draggable
+            class="min-h-[20px] border rounded p-4"
+            tag="ul"
+            :list="tasks"
+            :group="{ name: 'g1' }"
+            item-key="name"
+            animation="200"
+        >
+            <template #item="{ element }">
+            <li>
+                <p>@{{ element.name }}</p>
+                <nested-draggable :tasks="element.tasks" />
+            </li>
+            </template>
+        </draggable>
+    </script>
+
+    <script type="module">
+        app.component('nested-draggable', {
+            template: '#nested-draggable-template',
+             props: {
+                tasks: {
+                    required: true,
+                    type: Array
+                }
+            },
+
+        });
+    </script>

+ 1 - 0
packages/Longyi/Core/src/Resources/views/components/test.blade.php

@@ -0,0 +1 @@
+<h1>白香亦</h1>

+ 3 - 1
packages/Longyi/Core/src/Routes/admin-routes.php

@@ -29,9 +29,11 @@ Route::group(['middleware' => ['admin']], function () {
         Route::prefix('variants')->group(function () {
         Route::prefix('variants')->group(function () {
             Route::get('/{id}', [FlexibleVariantController::class, 'getVariant'])->name('admin.flexible_variant.variants.show');
             Route::get('/{id}', [FlexibleVariantController::class, 'getVariant'])->name('admin.flexible_variant.variants.show');
             Route::post('/', [FlexibleVariantController::class, 'createVariant'])->name('admin.flexible_variant.variants.store');
             Route::post('/', [FlexibleVariantController::class, 'createVariant'])->name('admin.flexible_variant.variants.store');
+            Route::post('{id}/save-variants', [FlexibleVariantController::class, 'saveVariants'])->name('admin.flexible_variant.variants.save-variants');
             Route::put('/{id}', [FlexibleVariantController::class, 'updateVariant'])->name('admin.flexible_variant.variants.update');
             Route::put('/{id}', [FlexibleVariantController::class, 'updateVariant'])->name('admin.flexible_variant.variants.update');
             Route::delete('/{id}', [FlexibleVariantController::class, 'deleteVariant'])->name('admin.flexible_variant.variants.destroy');
             Route::delete('/{id}', [FlexibleVariantController::class, 'deleteVariant'])->name('admin.flexible_variant.variants.destroy');
-            
+            Route::post('/{id}/images', [FlexibleVariantController::class, 'syncVariantImages'])->name('admin.flexible_variant.variants.images.sync');
+
             // Bulk operations
             // Bulk operations
             Route::post('/bulk/quantities', [FlexibleVariantController::class, 'bulkUpdateQuantities'])->name('admin.flexible_variant.variants.bulk.quantities');
             Route::post('/bulk/quantities', [FlexibleVariantController::class, 'bulkUpdateQuantities'])->name('admin.flexible_variant.variants.bulk.quantities');
             Route::post('/bulk/status', [FlexibleVariantController::class, 'bulkUpdateStatus'])->name('admin.flexible_variant.variants.bulk.status');
             Route::post('/bulk/status', [FlexibleVariantController::class, 'bulkUpdateStatus'])->name('admin.flexible_variant.variants.bulk.status');

+ 93 - 82
packages/Longyi/Core/src/Type/FlexibleVariant.php

@@ -2,28 +2,23 @@
 
 
 namespace Longyi\Core\Type;
 namespace Longyi\Core\Type;
 
 
+use Longyi\Core\Helpers\Indexers\Price\FlexibleVariant as FlexibleVariantIndexer;
 use Webkul\Product\Type\AbstractType;
 use Webkul\Product\Type\AbstractType;
 
 
 class FlexibleVariant extends AbstractType
 class FlexibleVariant extends AbstractType
 {
 {
     /**
     /**
      * Is a composite product type.
      * Is a composite product type.
-     *
-     * @var bool
      */
      */
     protected $isComposite = true;
     protected $isComposite = true;
 
 
     /**
     /**
      * Is a stockable product type.
      * Is a stockable product type.
-     *
-     * @var bool
      */
      */
     protected $isStockable = false;
     protected $isStockable = false;
 
 
     /**
     /**
      * Show quantity box.
      * Show quantity box.
-     *
-     * @var bool
      */
      */
     protected $showQuantityBox = false;
     protected $showQuantityBox = false;
 
 
@@ -31,67 +26,49 @@ class FlexibleVariant extends AbstractType
      * Has child products i.e. variants.
      * Has child products i.e. variants.
      * Set to false because flexible_variant uses a separate product_variants table
      * Set to false because flexible_variant uses a separate product_variants table
      * instead of Bagisto's standard child products system.
      * instead of Bagisto's standard child products system.
-     *
-     * @var bool
      */
      */
     protected $hasVariants = false;
     protected $hasVariants = false;
 
 
     /**
     /**
      * Product children price can be calculated.
      * Product children price can be calculated.
-     *
-     * @var bool
      */
      */
     protected $isChildrenCalculated = true;
     protected $isChildrenCalculated = true;
 
 
     /**
     /**
-     * Create product with flexible variants
-     *
-     * @param  array  $data
-     * @return \Webkul\Product\Contracts\Product
+     * Create product with flexible variants.
      */
      */
     public function create(array $data)
     public function create(array $data)
     {
     {
         $product = $this->productRepository->getModel()->create($data);
         $product = $this->productRepository->getModel()->create($data);
 
 
-        // Attach options to the product (many-to-many)
         if (isset($data['options']) && is_array($data['options'])) {
         if (isset($data['options']) && is_array($data['options'])) {
             foreach ($data['options'] as $optionData) {
             foreach ($data['options'] as $optionData) {
                 $product->options()->attach($optionData['id'], [
                 $product->options()->attach($optionData['id'], [
-                    'position' => $optionData['position'] ?? 0,
+                    'position'    => $optionData['position'] ?? 0,
                     'is_required' => $optionData['is_required'] ?? false,
                     'is_required' => $optionData['is_required'] ?? false,
-                    'meta' => isset($optionData['meta']) ? json_encode($optionData['meta']) : null,
+                    'meta'        => isset($optionData['meta']) ? json_encode($optionData['meta']) : null,
                 ]);
                 ]);
             }
             }
         }
         }
 
 
-        // NOTE: Variants are NOT auto-generated here
-        // They will be manually created via the admin panel or API
-        // This is the key difference from Configurable product type
-
         return $product;
         return $product;
     }
     }
 
 
     /**
     /**
-     * Update product with flexible variants
-     *
-     * @param  array  $data
-     * @param  int  $id
-     * @param  string  $attribute
-     * @return \Webkul\Product\Contracts\Product
+     * Update product with flexible variants.
      */
      */
     public function update(array $data, $id, $attribute = 'id')
     public function update(array $data, $id, $attribute = 'id')
     {
     {
         $product = parent::update($data, $id, $attribute);
         $product = parent::update($data, $id, $attribute);
 
 
-        // Update options (many-to-many sync)
         if (isset($data['options']) && is_array($data['options'])) {
         if (isset($data['options']) && is_array($data['options'])) {
             $syncData = [];
             $syncData = [];
-            
+
             foreach ($data['options'] as $optionData) {
             foreach ($data['options'] as $optionData) {
                 $syncData[$optionData['id']] = [
                 $syncData[$optionData['id']] = [
-                    'position' => $optionData['position'] ?? 0,
+                    'position'    => $optionData['position'] ?? 0,
                     'is_required' => $optionData['is_required'] ?? false,
                     'is_required' => $optionData['is_required'] ?? false,
-                    'meta' => isset($optionData['meta']) ? json_encode($optionData['meta']) : null,
+                    'meta'        => isset($optionData['meta']) ? json_encode($optionData['meta']) : null,
                 ];
                 ];
             }
             }
 
 
@@ -102,91 +79,125 @@ class FlexibleVariant extends AbstractType
     }
     }
 
 
     /**
     /**
-     * Get product's minimal price
-     *
-     * @return float
+     * Returns the price indexer for this product type.
      */
      */
-    public function getMinimalPrice()
+    public function getPriceIndexer()
     {
     {
-        // Get the minimum price from all active variants
-        $variantRepository = app(\Longyi\Core\Repositories\ProductVariantRepository::class);
-        
-        $variants = $variantRepository->getSaleableByProduct($this->product->id);
+        return app(FlexibleVariantIndexer::class);
+    }
 
 
-        if ($variants->isEmpty()) {
-            return 0;
+    /**
+     * Return the parent product's own ID so that catalog rules
+     * store the rule entry against the parent (not individual variants,
+     * which live in a separate table and can't be FK'd to products).
+     */
+    public function getChildrenIds(): array
+    {
+        return [$this->product->id];
+    }
+
+    /**
+     * Get product minimal price from price index.
+     */
+    public function getMinimalPrice()
+    {
+        if ($priceIndex = $this->getPriceIndex()) {
+            return $priceIndex->min_price;
         }
         }
 
 
-        return $variants->min(function ($variant) {
-            return $variant->getEffectivePrice();
-        });
+        return $this->getMinimalPriceFromVariants();
     }
     }
 
 
     /**
     /**
-     * Get product's maximum price
-     *
-     * @return float
+     * Get product maximum price from price index.
      */
      */
     public function getMaximumPrice()
     public function getMaximumPrice()
     {
     {
-        // Get the maximum price from all active variants
-        $variantRepository = app(\Longyi\Core\Repositories\ProductVariantRepository::class);
-        
-        $variants = $variantRepository->getSaleableByProduct($this->product->id);
+        if ($priceIndex = $this->getPriceIndex()) {
+            return $priceIndex->max_price;
+        }
+
+        return $this->getMaximumPriceFromVariants();
+    }
+
+    /**
+     * Get product prices for display.
+     */
+    public function getProductPrices()
+    {
+        $minPrice = $this->getMinimalPrice();
+        $regularMinPrice = $this->getRegularMinimalPrice();
+
+        return [
+            'regular' => [
+                'price'           => core()->convertPrice($regularMinPrice),
+                'formatted_price' => core()->currency($regularMinPrice),
+            ],
+            'final' => [
+                'price'           => core()->convertPrice($minPrice),
+                'formatted_price' => core()->currency($minPrice),
+            ],
+        ];
+    }
+
+    /**
+     * Fallback: compute minimal price directly from variants.
+     */
+    protected function getMinimalPriceFromVariants(): float
+    {
+        $variants = $this->getSaleableVariants();
 
 
         if ($variants->isEmpty()) {
         if ($variants->isEmpty()) {
             return 0;
             return 0;
         }
         }
 
 
-        return $variants->max(function ($variant) {
-            return $variant->getEffectivePrice();
-        });
+        return $variants->min(fn ($v) => $v->getBasicEffectivePrice());
     }
     }
 
 
     /**
     /**
-     * Check if product has sufficient quantity
-     *
-     * @param  int  $qty
-     * @return bool
+     * Fallback: compute maximum price directly from variants.
      */
      */
-    public function haveSufficientQuantity(int $qty): bool
+    protected function getMaximumPriceFromVariants(): float
     {
     {
-        // Check total quantity across all saleable variants
-        $variantRepository = app(\Longyi\Core\Repositories\ProductVariantRepository::class);
-        
-        $variants = $variantRepository->getSaleableByProduct($this->product->id);
+        $variants = $this->getSaleableVariants();
 
 
-        $totalQuantity = $variants->sum('quantity');
+        if ($variants->isEmpty()) {
+            return 0;
+        }
 
 
-        return $totalQuantity >= $qty;
+        return $variants->max(fn ($v) => $v->getBasicEffectivePrice());
     }
     }
 
 
     /**
     /**
-     * Get product total quantity
-     *
-     * @return int
+     * Get saleable variants (cached via relation).
      */
      */
+    protected function getSaleableVariants()
+    {
+        if ($this->product->relationLoaded('flexibleVariants')) {
+            return $this->product->flexibleVariants->filter(fn ($v) => $v->isSaleable());
+        }
+
+        return app(\Longyi\Core\Repositories\ProductVariantRepository::class)
+            ->getSaleableByProduct($this->product->id);
+    }
+
+    public function haveSufficientQuantity(int $qty): bool
+    {
+        return $this->getSaleableVariants()->sum('quantity') >= $qty;
+    }
+
     public function totalQuantity(): int
     public function totalQuantity(): int
     {
     {
-        $variantRepository = app(\Longyi\Core\Repositories\ProductVariantRepository::class);
-        
-        $variants = $variantRepository->getByProduct($this->product->id, true);
+        $variants = $this->product->relationLoaded('flexibleVariants')
+            ? $this->product->flexibleVariants->where('status', true)
+            : app(\Longyi\Core\Repositories\ProductVariantRepository::class)
+                ->getByProduct($this->product->id, true);
 
 
         return $variants->sum('quantity');
         return $variants->sum('quantity');
     }
     }
 
 
-    /**
-     * Is product saleable?
-     *
-     * @return bool
-     */
     public function isSaleable(): bool
     public function isSaleable(): bool
     {
     {
-        // Product is saleable if at least one variant is saleable
-        $variantRepository = app(\Longyi\Core\Repositories\ProductVariantRepository::class);
-        
-        $saleableVariants = $variantRepository->getSaleableByProduct($this->product->id);
-
-        return $saleableVariants->isNotEmpty();
+        return $this->getSaleableVariants()->isNotEmpty();
     }
     }
 }
 }

+ 2 - 0
packages/Webkul/Admin/package.json

@@ -22,12 +22,14 @@
     "@vee-validate/rules": "^4.9.1",
     "@vee-validate/rules": "^4.9.1",
     "@vitejs/plugin-vue": "^4.2.3",
     "@vitejs/plugin-vue": "^4.2.3",
     "dotenv": "^16.4.7",
     "dotenv": "^16.4.7",
+    "element-plus": "^2.13.3",
     "flatpickr": "^4.6.13",
     "flatpickr": "^4.6.13",
     "fs": "^0.0.1-security",
     "fs": "^0.0.1-security",
     "mitt": "^3.0.1",
     "mitt": "^3.0.1",
     "playwright": "^1.48.1",
     "playwright": "^1.48.1",
     "readline-sync": "^1.4.10",
     "readline-sync": "^1.4.10",
     "vee-validate": "^4.9.1",
     "vee-validate": "^4.9.1",
+    "vue-draggable-plus": "^0.6.1",
     "vue-flatpickr": "^2.3.0",
     "vue-flatpickr": "^2.3.0",
     "vuedraggable": "^4.1.0"
     "vuedraggable": "^4.1.0"
   }
   }

+ 51 - 0
packages/Webkul/Admin/readme.md

@@ -0,0 +1,51 @@
+# package.json包的作用
+
+- @playwright/test 编写测试用例用于自动化测试
+- @types/node 是 Node.js 核心模块的 TypeScript 类型定义包,它的作用是为 TypeScript 项目提供 Node.js 内置 API(如 fs、path、process、http 等)的类型声明。
+- autoprefixer 给css 代码添加各个浏览器的特有前缀
+- laravel-vite-plugin 在laravel中集成vite
+- postcss css语法转换工具(你可以自己规定一些css的写法,然后通过编写postcss插件将你规定的css写法转换为浏览器可读的css代码)
+- vue-cal vue日历组件
+- @vitejs/plugin-vue  Vite 官方提供的 Vue 3 单文件组件(SFC)插件,它的主要作用是在 Vite 构建的项目中支持 .vue 文件的解析和编译。
+- dotenv Dotenv is a zero-dependency module that loads environment variables from a .env file into process.env
+- flatpickr data-time picker (bagisto使用的是这个)
+- vue-flatpickr 这个包是flatpicker的vue1版本,严重过时,不要使用。
+- fs 这不是一个包(fs这个包名被收回了),没有用
+- mitt 发布订阅模式的库
+- playwright  Playwright is a framework for Web Testing and Automation. It allows testing Chromium, Firefox and WebKit with a single API. Playwright is built to enable cross-browser web automation that is ever-green, capable, reliable, and fast.
+- readline-sync 实现通过控制台跟用户对话
+- vee-validate 是一个专为 Vue.js 应用程序设计的强大表单验证库,它让开发者能够以声明式、组件化的方式轻松实现复杂表单的实时校验、错误提示与用户交互体验优化
+- vuedraggable vue 拖拽组件 在npmjs中vue2版本和vue3版本公用一个包名,如果要安装vue3版本需要执行npm install vuedraggable@next。
+- vue-draggable-plus 是另外一个vue拖拽组件,作者是vue团队的成员,比较新,也是基于SortableJS。文档:https://vue-draggable-plus.pages.dev/guide/
+> vuedraggable是基于SortableJS的,有些props文档中没有列出,需要参考SortableJS的文档,比如group,ghost-class属性
+
+# Blade模板文件中的语法
+例子:
+```html
+<script type="text/x-template" id="v-configuration-search-template">
+    <h3 class="text-base font-semibold text-gray-800 dark:text-white">
+        @{{ "@lang('longyi::app.flexible-variant.options.title')" }}
+    </h3>
+</script>
+```
+`@`符号告诉Blade模板引擎这个`{{}}`是JavaScript框架的模板语法
+
+
+v-form v-field 等参考 vee-validate 文档: https://vee-validate.logaretm.com/
+
+
+## 组件库和图标
+组件库使用element-plus
+
+图标在这个网站找: https://www.xicons.org/#/
+
+
+### Clear Cache
+
+```bash
+php artisan config:clear
+php artisan cache:clear
+php artisan view:clear
+```
+
+php artisan vendor:publish --provider="Longyi\Core\Providers\LongyiCoreServiceProvider" --force

+ 14 - 13
packages/Webkul/Admin/src/Http/Controllers/Catalog/ProductController.php

@@ -44,7 +44,8 @@ class ProductController extends Controller
         protected ProductInventoryRepository $productInventoryRepository,
         protected ProductInventoryRepository $productInventoryRepository,
         protected ProductRepository $productRepository,
         protected ProductRepository $productRepository,
         protected CustomerRepository $customerRepository,
         protected CustomerRepository $customerRepository,
-    ) {}
+    ) {
+    }
 
 
     /**
     /**
      * Display a listing of the resource.
      * Display a listing of the resource.
@@ -89,19 +90,19 @@ class ProductController extends Controller
     {
     {
         $productType = request()->input('type');
         $productType = request()->input('type');
         $validationRules = [
         $validationRules = [
-            'type'                => 'required',
+            'type' => 'required',
             'attribute_family_id' => 'required',
             'attribute_family_id' => 'required',
-            'sku'                 => ['required', 'unique:products,sku', new Slug],
+            'sku' => ['required', 'unique:products,sku', new Slug],
         ];
         ];
         if ($productType !== 'flexible_variant') {
         if ($productType !== 'flexible_variant') {
-            $validationRules['super_attributes']    = 'array|min:1';
-            $validationRules['super_attributes.*']  = 'array|min:1';
+            $validationRules['super_attributes'] = 'array|min:1';
+            $validationRules['super_attributes.*'] = 'array|min:1';
         }
         }
         $this->validate(request(), $validationRules);
         $this->validate(request(), $validationRules);
         if (
         if (
             $productType !== 'flexible_variant'
             $productType !== 'flexible_variant'
             && ProductType::hasVariants(request()->input('type'))
             && ProductType::hasVariants(request()->input('type'))
-            && ! request()->has('super_attributes')
+            && !request()->has('super_attributes')
         ) {
         ) {
             $configurableFamily = $this->attributeFamilyRepository
             $configurableFamily = $this->attributeFamilyRepository
                 ->find(request()->input('attribute_family_id'));
                 ->find(request()->input('attribute_family_id'));
@@ -180,7 +181,7 @@ class ProductController extends Controller
         Event::dispatch('catalog.product.update.after', $product);
         Event::dispatch('catalog.product.update.after', $product);
 
 
         return response()->json([
         return response()->json([
-            'message'      => __('admin::app.catalog.products.saved-inventory-message'),
+            'message' => __('admin::app.catalog.products.saved-inventory-message'),
             'updatedTotal' => $this->productInventoryRepository->where('product_id', $product->id)->sum('qty'),
             'updatedTotal' => $this->productInventoryRepository->where('product_id', $product->id)->sum('qty'),
         ]);
         ]);
     }
     }
@@ -298,7 +299,7 @@ class ProductController extends Controller
             Event::dispatch('catalog.product.update.before', $productId);
             Event::dispatch('catalog.product.update.before', $productId);
 
 
             $product = $this->productRepository->update([
             $product = $this->productRepository->update([
-                'status'  => $massUpdateRequest->input('value'),
+                'status' => $massUpdateRequest->input('value'),
             ], $productId, ['status']);
             ], $productId, ['status']);
 
 
             Event::dispatch('catalog.product.update.after', $product);
             Event::dispatch('catalog.product.update.after', $product);
@@ -352,10 +353,10 @@ class ProductController extends Controller
         $channelId = $this->customerRepository->find(request('customer_id'))->channel_id ?? null;
         $channelId = $this->customerRepository->find(request('customer_id'))->channel_id ?? null;
 
 
         $params = [
         $params = [
-            'index'      => $indexNames ?? null,
-            'name'       => request('query'),
-            'sort'       => 'created_at',
-            'order'      => 'desc',
+            'index' => $indexNames ?? null,
+            'name' => request('query'),
+            'sort' => 'created_at',
+            'order' => 'desc',
             'channel_id' => $channelId,
             'channel_id' => $channelId,
         ];
         ];
 
 
@@ -384,7 +385,7 @@ class ProductController extends Controller
     public function download($productId, $attributeId)
     public function download($productId, $attributeId)
     {
     {
         $productAttribute = $this->productAttributeValueRepository->findOneWhere([
         $productAttribute = $this->productAttributeValueRepository->findOneWhere([
-            'product_id'   => $productId,
+            'product_id' => $productId,
             'attribute_id' => $attributeId,
             'attribute_id' => $attributeId,
         ]);
         ]);
 
 

+ 30 - 2
packages/Webkul/Admin/src/Resources/assets/css/app.css

@@ -9,7 +9,7 @@
     font-style: normal;
     font-style: normal;
     font-display: block;
     font-display: block;
 }
 }
-
+/**@layer 声明了一个层叠层,同一层内的规则将级联在一起,这给予了开发者对层叠机制的更多控制。*/
 @layer components {
 @layer components {
     ::selection {
     ::selection {
         background-color: rgba(0, 68, 242, .2);
         background-color: rgba(0, 68, 242, .2);
@@ -404,7 +404,18 @@
     .secondary-button {
     .secondary-button {
         @apply flex cursor-pointer place-content-center items-center gap-x-1 whitespace-nowrap rounded-md border-2 border-blue-600 bg-white px-3 py-1.5 font-semibold text-blue-600 transition-all hover:bg-[#eff6ff61] focus:bg-[#eff6ff61] dark:border-gray-400 dark:bg-gray-800 dark:text-white dark:hover:opacity-80;
         @apply flex cursor-pointer place-content-center items-center gap-x-1 whitespace-nowrap rounded-md border-2 border-blue-600 bg-white px-3 py-1.5 font-semibold text-blue-600 transition-all hover:bg-[#eff6ff61] focus:bg-[#eff6ff61] dark:border-gray-400 dark:bg-gray-800 dark:text-white dark:hover:opacity-80;
     }
     }
-
+    .middle-button{
+        @apply flex cursor-pointer place-content-center items-center gap-x-1 whitespace-nowrap rounded-md border-2 border-blue-600 bg-white px-2 py-1 font-normal text-blue-600 text-sm transition-all hover:bg-[#eff6ff61] focus:bg-[#eff6ff61] dark:border-gray-400 dark:bg-gray-800 dark:text-white dark:hover:opacity-80;
+    }
+    .small-white-button{
+        @apply flex cursor-pointer place-content-center items-center gap-x-1 whitespace-nowrap rounded-md border border-gray-600 bg-white px-2 py-1 font-normal  text-12 transition-all hover:bg-[#eff6ff61] focus:bg-[#eff6ff61];
+    }
+    .small-blue-button{
+        @apply flex cursor-pointer place-content-center items-center gap-x-1 whitespace-nowrap rounded-md border border-blue-700 bg-blue-600 px-2 py-1 font-normal text-white text-12 transition-all focus:opacity-[0.9] hover:opacity-[0.9];
+    }
+    .small-red-button{
+        @apply flex cursor-pointer place-content-center items-center gap-x-1 whitespace-nowrap rounded-md border border-red-500 bg-red-600 px-2 py-1 font-normal text-white text-12 transition-all focus:opacity-[0.9] hover:opacity-[0.9];
+    }
     .transparent-button {
     .transparent-button {
         @apply flex cursor-pointer appearance-none place-content-center items-center gap-x-1 whitespace-nowrap rounded-md border-2 border-transparent px-3 py-1.5 font-semibold text-gray-600 transition-all marker:shadow hover:bg-gray-100 focus:bg-gray-100 dark:hover:bg-gray-950;
         @apply flex cursor-pointer appearance-none place-content-center items-center gap-x-1 whitespace-nowrap rounded-md border-2 border-transparent px-3 py-1.5 font-semibold text-gray-600 transition-all marker:shadow hover:bg-gray-100 focus:bg-gray-100 dark:hover:bg-gray-950;
     }
     }
@@ -541,6 +552,13 @@
     .CodeMirror {
     .CodeMirror {
         @apply !h-[calc(100vh-367px)]
         @apply !h-[calc(100vh-367px)]
     }
     }
+
+    .admin-common-input{
+        @apply w-full rounded-md border px-3 py-2.5 text-sm text-gray-600 transition-all hover:border-gray-400 focus:border-gray-400 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-gray-400 dark:focus:border-gray-400
+    }
+    .admin-common-input.error{
+        @apply border !border-red-600 hover:border-red-600
+    }
 }
 }
 
 
 .tox .tox-toolbar__group:last-child button {
 .tox .tox-toolbar__group:last-child button {
@@ -556,4 +574,14 @@
 
 
 .tox .tox-toolbar__group:last-child button[aria-disabled="true"] {
 .tox .tox-toolbar__group:last-child button[aria-disabled="true"] {
     @apply cursor-not-allowed opacity-50;
     @apply cursor-not-allowed opacity-50;
+}
+
+.icon {
+  display: inline;
+  width: 1em;
+  height: 1em;
+  vertical-align: -0.15em;
+  fill: currentColor;
+  overflow: hidden;
+  stroke: currentColor;
 }
 }

+ 3 - 0
packages/Webkul/Admin/src/Resources/assets/css/flexible_variant.css

@@ -0,0 +1,3 @@
+.test-flexible-h1{
+    color: #1248a6;
+}

+ 125 - 0
packages/Webkul/Admin/src/Resources/assets/js/VueComponents/LongyiOverlay.vue

@@ -0,0 +1,125 @@
+<template>
+    <transition
+        name="drawer-overlay"
+        enter-from-class="opacity-0"
+        enter-to-class="opacity-100"
+        leave-from-class="opacity-100"
+        leave-to-class="opacity-0"
+    >
+        <div @click.stop="overlayClick"
+            class="fixed inset-0 bg-gray-500 bg-opacity-50 transition-opacity w-full h-full"
+            :style="{ 'z-index': zIndex }"
+            v-show="isActive"
+        >
+            <!-- Content -->
+            <transition
+                name="drawer"
+                :enter-from-class="enterFromLeaveToClasses"
+                enter-active-class="transform transition duration-200 ease-in-out"
+                
+                leave-active-class="transform transition duration-200 ease-in-out"
+                :leave-to-class="enterFromLeaveToClasses"
+            >
+                <div @click.stop="() => {}"
+                    class="fixed"
+                    :class="[{
+                        'inset-x-0 top-0': position == 'top',
+                        'inset-x-0 bottom-0': position == 'bottom',
+                        'inset-y-0 ltr:right-0 rtl:left-0': position == 'right',
+                        'inset-y-0 ltr:left-0 rtl:right-0': position == 'left',
+                        'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2': position == 'center',
+                    }, customClass]"
+                    
+                    v-if="isActive"
+                >
+                   
+                    <slot></slot>
+                    
+                    
+                </div>
+            </transition>
+
+        
+        </div>
+    </transition>
+
+    
+
+</template>
+
+<script>
+import { overlayManager } from "../stores/overlayManager.js";
+export default {
+    name: 'LongyiOverlay',
+    props: {
+        id: String,
+        position: {
+            type: String,
+            default: 'center',
+        },
+        isActive: {
+            type: Boolean,
+            default: false,
+        },
+        isOverlayClose: {
+            type: Boolean,
+            default: false,
+        },
+        customClass: {
+            type: String,
+            default: '',
+        }
+        
+    },
+
+    data() {
+        return {
+            zIndex: 0,
+            
+        };
+    },
+    mounted() {
+        this.zIndex = overlayManager.register(this.id);
+    },
+    watch: {
+        isActive: function(newVal, oldVal) {
+            if(newVal) {
+                document.body.style.overflow = 'hidden';
+            } else {
+                document.body.style.overflow ='auto';
+
+            }
+        },
+
+    },
+
+    computed: {
+        enterFromLeaveToClasses() {
+            if (this.position == 'top') {
+                return '-translate-y-full';
+            } else if (this.position == 'bottom') {
+                return 'translate-y-full';
+            } else if (this.position == 'left') {
+                return 'ltr:-translate-x-full rtl:translate-x-full';
+            } else if (this.position == 'right') {
+                return 'ltr:translate-x-full rtl:-translate-x-full';
+            } else if (this.position == 'center') {
+                return 'scale-95 opacity-0';
+            }
+        }
+    },
+
+    methods: {
+        overlayClick() {
+            if(this.isOverlayClose) {
+                this.close();
+            }
+        },
+        close() {
+        
+            this.$emit('close', { isActive: this.isActive });
+        }
+    },
+}
+</script>
+

+ 18 - 0
packages/Webkul/Admin/src/Resources/assets/js/VueComponents/readme.md

@@ -0,0 +1,18 @@
+VueComponents文件夹中的组件是注册到全局的通用组件,属于前端的vue组件,任何页面都可以使用。
+
+vue组件的大驼峰写法(比如:`<LongyiOverlay></LongyiOverlay>`)仅在SFC中有效。在blade模板中要使用短横线写法:`<longyi-overlay></longyi-overlay>`
+
+Admin/src/Resources/views/components 中的组件是Blade模板引擎的组件,是通过laravel注册的,属于服务端页面模板组件。
+
+LongyiOverlay 组件使用例子:
+```html
+<longyi-overlay id="optionmodal" :isActive="showOptionModal" @close="toggleOptionModal(false)">
+    <div class="w-1/3 h-1/3 bg-orange-300 overflow-y-auto">
+        <p class="text-xl">Modal content for adding/editing variants</p>
+        <p class="text-xl">Modal content for adding/editing variants</p>
+    </div>
+</longyi-overlay>
+```
+- 需要在app.js中将LongyiOverlay注册为全局组件。引入element-plus后,用不上LongyiOverlay了
+
+v-model的原理: https://cn.vuejs.org/guide/components/v-model.html

+ 13 - 0
packages/Webkul/Admin/src/Resources/assets/js/app.js

@@ -126,11 +126,23 @@ import Emitter from "./plugins/emitter";
 import Flatpickr from "./plugins/flatpickr";
 import Flatpickr from "./plugins/flatpickr";
 import VeeValidate from "./plugins/vee-validate";
 import VeeValidate from "./plugins/vee-validate";
 import Draggable from "./plugins/draggable";
 import Draggable from "./plugins/draggable";
+import VueDraggablePlus from "./plugins/vuedraggableplus";
+// 引入element-plus组件库
+import ElementPlus from 'element-plus';
+import 'element-plus/dist/index.css';
+import 'element-plus/theme-chalk/dark/css-vars.css';
+
+/**
+ * Global components registration.
+ */
 import VueCal from 'vue-cal';
 import VueCal from 'vue-cal';
 import 'vue-cal/dist/vuecal.css';
 import 'vue-cal/dist/vuecal.css';
 
 
+
+app.use(ElementPlus,{ zIndex: 12000 });
 app.component('vue-cal', VueCal);
 app.component('vue-cal', VueCal);
 
 
+
 [
 [
     Admin,
     Admin,
     Axios,
     Axios,
@@ -139,6 +151,7 @@ app.component('vue-cal', VueCal);
     Flatpickr,
     Flatpickr,
     VeeValidate,
     VeeValidate,
     Draggable,
     Draggable,
+    VueDraggablePlus,
 ].forEach((plugin) => app.use(plugin));
 ].forEach((plugin) => app.use(plugin));
 
 
 /**
 /**

+ 2 - 2
packages/Webkul/Admin/src/Resources/assets/js/plugins/vee-validate.js

@@ -25,7 +25,7 @@ import sin from "../../locales/sin.json";
 import tr from "@vee-validate/i18n/dist/locale/tr.json";
 import tr from "@vee-validate/i18n/dist/locale/tr.json";
 import uk from "@vee-validate/i18n/dist/locale/uk.json";
 import uk from "@vee-validate/i18n/dist/locale/uk.json";
 import zh_CN from "@vee-validate/i18n/dist/locale/zh_CN.json";
 import zh_CN from "@vee-validate/i18n/dist/locale/zh_CN.json";
-import { all } from '@vee-validate/rules';
+import { all } from '@vee-validate/rules'; // 全局验证器规则包 https://vee-validate.logaretm.com/v4/guide/global-validators#vee-validaterules
 
 
 window.defineRule = defineRule;
 window.defineRule = defineRule;
 
 
@@ -43,7 +43,7 @@ export default {
         /**
         /**
          * Registration of all global validators.
          * Registration of all global validators.
          */
          */
-        Object.entries(all).forEach(([name, rule]) => defineRule(name, rule));
+        Object.entries(all).forEach(([name, rule]) => defineRule(name, rule)); // 注册全局验证器
 
 
         /**
         /**
          * This regular expression allows phone numbers with the following conditions:
          * This regular expression allows phone numbers with the following conditions:

+ 10 - 0
packages/Webkul/Admin/src/Resources/assets/js/plugins/vuedraggableplus.js

@@ -0,0 +1,10 @@
+import {VueDraggable as VueDraggablePlus} from 'vue-draggable-plus';
+
+export default {
+    install: (app) => {
+        /**
+         * Global component registration;
+         */
+        app.component("VueDraggablePlus", VueDraggablePlus);
+    },
+};

+ 27 - 0
packages/Webkul/Admin/src/Resources/assets/js/stores/overlayManager.js

@@ -0,0 +1,27 @@
+import { reactive, readonly } from 'vue'
+
+const state = reactive({
+  maxZIndex: 20000,
+  stack: {},
+  STEP: 10
+})
+
+export const overlayManager = {
+  // 只读状态,防止外部直接修改
+  state: readonly(state),
+
+  register(id) {
+    // 当栈空时可重置计数器(可选)
+    if (Object.keys(state.stack).length === 0) {
+      state.maxZIndex = 20000;
+    }
+    const zIndex = state.maxZIndex;
+    state.maxZIndex += state.STEP;
+    state.stack[id.toString()] = zIndex;
+    return zIndex;
+  },
+
+  unregister(id) {
+    delete state.stack[id.toString()];
+  }
+}

+ 17 - 5
packages/Webkul/Admin/src/Resources/views/catalog/products/edit.blade.php

@@ -148,12 +148,15 @@
 
 
                 $isSingleColumn = $groupedColumns->count() !== 2;
                 $isSingleColumn = $groupedColumns->count() !== 2;
             @endphp
             @endphp
-
+            <!--
+                添加overflow-auto为了让flexible_cariant.blade.php中的el-table 组件能随窗口宽度的改变而自动改变宽度
+                https://juejin.cn/post/7274839871277482038
+            -->
             @foreach ($groupedColumns as $column => $groups)
             @foreach ($groupedColumns as $column => $groups)
 
 
                 {!! view_render_event("bagisto.admin.catalog.product.edit.form.column_{$column}.before", ['product' => $product]) !!}
                 {!! view_render_event("bagisto.admin.catalog.product.edit.form.column_{$column}.before", ['product' => $product]) !!}
 
 
-                <div class="flex flex-col gap-2 {{ $column == 1 ? 'flex-1 max-xl:flex-auto' : 'w-[360px] max-w-full max-sm:w-full' }}">
+                <div class="flex flex-col gap-2 {{ $column == 1 ? 'flex-1 max-xl:flex-auto overflow-auto' : 'w-[360px] max-w-full max-sm:w-full' }}">
                     @foreach ($groups as $group)
                     @foreach ($groups as $group)
                         @php $customAttributes = $product->getEditableAttributes($group); @endphp
                         @php $customAttributes = $product->getEditableAttributes($group); @endphp
 
 
@@ -238,9 +241,14 @@
                         <!-- Videos View Blade File -->
                         <!-- Videos View Blade File -->
                         @include('admin::catalog.products.edit.videos')
                         @include('admin::catalog.products.edit.videos')
 
 
+                        <h1>1111111111111111111--start @php echo $product->type; @endphp</h1>
                         <!-- Product Type View Blade File -->
                         <!-- Product Type View Blade File -->
-                        @includeIf('admin::catalog.products.edit.types.' . $product->type)
-
+                        @if ($product->type === 'flexible_variant')
+                            @includeIf('longyi::admin.catalog.products.edit.types.' . $product->type)    
+                        @else
+                            @includeIf('admin::catalog.products.edit.types.' . $product->type)
+                        @endif
+                        <h1>1111111111111111111--end</h1>
                         <!-- Related, Cross Sells, Up Sells View Blade File -->
                         <!-- Related, Cross Sells, Up Sells View Blade File -->
                         @include('admin::catalog.products.edit.links')
                         @include('admin::catalog.products.edit.links')
 
 
@@ -267,7 +275,11 @@
                             @include('admin::catalog.products.edit.videos')
                             @include('admin::catalog.products.edit.videos')
 
 
                             <!-- Product Type View Blade File -->
                             <!-- Product Type View Blade File -->
-                            @includeIf('admin::catalog.products.edit.types.' . $product->type)
+                            @if ($product->type === 'flexible_variant')
+                                @includeIf('longyi::admin.catalog.products.edit.types.' . $product->type)    
+                            @else
+                                @includeIf('admin::catalog.products.edit.types.' . $product->type)
+                            @endif
 
 
                             <!-- Related, Cross Sells, Up Sells View Blade File -->
                             <!-- Related, Cross Sells, Up Sells View Blade File -->
                             @include('admin::catalog.products.edit.links')
                             @include('admin::catalog.products.edit.links')

+ 1 - 1
packages/Webkul/Admin/src/Resources/views/components/media/images.blade.php

@@ -63,7 +63,7 @@
                                 @lang('admin::app.components.media.images.add-image-btn')
                                 @lang('admin::app.components.media.images.add-image-btn')
                                 
                                 
                                 <span class="text-xs">
                                 <span class="text-xs">
-                                    @lang('admin::app.components.media.images.allowed-types')
+                                    @lang('admin::app.components.media.images.allowed-types')45678
                                 </span>
                                 </span>
                             </p>
                             </p>
 
 

+ 25 - 1
packages/Webkul/Admin/tailwind.config.js

@@ -1,6 +1,12 @@
 /** @type {import('tailwindcss').Config} */
 /** @type {import('tailwindcss').Config} */
 module.exports = {
 module.exports = {
-    content: ["./src/Resources/**/*.blade.php", "./src/Resources/**/*.js"],
+    // 哪些文件中包含tailwind的类名,tailwind会扫描这些文件来生成对应的CSS
+    content: [
+        "./src/Resources/**/*.blade.php", 
+        "./src/Resources/**/*.js", 
+        "./src/Resources/**/*.vue", 
+        "../../Longyi/Core/src/Resources/**/*.blade.php"
+    ],
 
 
     theme: {
     theme: {
         container: {
         container: {
@@ -33,6 +39,24 @@ module.exports = {
             fontFamily: {
             fontFamily: {
                 inter: ['Inter'],
                 inter: ['Inter'],
                 icon: ['icomoon']
                 icon: ['icomoon']
+            },
+
+            fontSize: {
+                '12': ['0.75rem'], 
+                '14': ['0.875rem'], 
+                '16': ['1rem'],
+                '18': ['1.125rem'],
+                '20': ['1.25rem'],
+                '22': ['1.375rem'],
+                '24': ['1.5rem'],
+                '26': ['1.625rem'],
+                '28': ['1.75rem'],
+                '30': ['1.875rem'],
+                '32': ['2rem'],
+                '34': ['2.125rem'],
+                '36': ['2.25rem'],
+                '38': ['2.375rem'],
+                '40': ['2.5rem'],
             }
             }
         },
         },
     },
     },

+ 1 - 0
packages/Webkul/Admin/vite.config.js

@@ -30,6 +30,7 @@ export default defineConfig(({ mode }) => {
                 buildDirectory: "themes/admin/default/build",
                 buildDirectory: "themes/admin/default/build",
                 input: [
                 input: [
                     "src/Resources/assets/css/app.css",
                     "src/Resources/assets/css/app.css",
+                    "src/Resources/assets/css/flexible_variant.css",
                     "src/Resources/assets/js/app.js",
                     "src/Resources/assets/js/app.js",
                     "src/Resources/assets/js/chart.js",
                     "src/Resources/assets/js/chart.js",
                 ],
                 ],

+ 103 - 0
packages/Webkul/BagistoApi/README.md

@@ -0,0 +1,103 @@
+# Bagisto API Platform
+
+Comprehensive REST and GraphQL APIs for seamless e-commerce integration and extensibility.
+
+## Installation
+
+### Method 1: Quick Start (Composer Installation – Recommended)
+
+The fastest way to get started:
+
+```bash
+composer require bagisto/bagisto-api
+php artisan bagisto-api-platform:install
+```
+
+Your APIs are now ready! Access them at:
+- **REST API Docs**: `https://your-domain.com/api/docs`
+- **GraphQL Playground**: `https://your-domain.com/graphql`
+ 
+### Method 2: Manual Installation
+
+Use this method if you need more control over the setup.
+
+#### Step 1: Download and Extract
+
+1. Download the BagistoApi package from [GitHub](https://github.com/bagisto/bagisto-api)
+2. Extract it to: `packages/Webkul/BagistoApi/`
+
+#### Step 2: Register Service Provider
+
+Edit `bootstrap/providers.php`:
+
+```php
+<?php
+
+return [
+    // ...existing providers...
+    Webkul\BagistoApi\Providers\BagistoApiServiceProvider::class,
+    // ...rest of providers...
+];
+```
+
+#### Step 3: Update Autoloading
+
+Edit `composer.json` and update the `autoload` section:
+
+```json
+{
+  "autoload": {
+    "psr-4": {
+      "Webkul\\BagistoApi\\": "packages/Webkul/BagistoApi/src"
+    }
+  }
+}
+```
+
+#### Step 4: Install Dependencies
+
+```bash
+# Install required packages
+composer require api-platform/laravel:v4.1.25
+composer require api-platform/graphql:v4.2.3
+```
+
+#### Step 5: Run the installation
+```bash
+php artisan bagisto-api-platform:install
+```
+
+#### Step 9: Environment Setup (Update in the .env)
+```bash
+STOREFRONT_DEFAULT_RATE_LIMIT=100
+STOREFRONT_CACHE_TTL=60
+STOREFRONT_KEY_PREFIX=storefront_key_
+STOREFRONT_PLAYGROUND_KEY=pk_storefront_xxxxxxxxxxxxxxxxxxxxxxxxxx 
+API_PLAYGROUND_AUTO_INJECT_STOREFRONT_KEY=true
+```
+### Access Points
+
+Once verified, access the APIs at:
+
+- **REST API (Shop)**: [https://your-domain.com/api/shop/](https://api-demo.bagisto.com/api/shop)
+- **REST API (Admin)**: [https://your-domain.com/api/admin/](https://api-demo.bagisto.com/api/admin)
+- **GraphQL Endpoint**: https://your-domain.com/graphql`
+- **GraphQL Playground**: [https://your-domain.com/graphqli](https://api-demo.bagisto.com/api/graphiql?)
+
+## Documentation
+- Bagisto API: [Demo Page](https://api-demo.bagisto.com/api) 
+- API Documentation: [Bagisto API Docs](https://api-docs.bagisto.com/)
+- GraphQL Playground: [Interactive Playground](https://api-demo.bagisto.com/graphiql)
+ 
+## Support
+
+For issues and questions, please visit:
+- [GitHub Issues](https://github.com/bagisto/bagisto-api-platform/issues)
+- [Bagisto Documentation](https://bagisto.com/docs)
+- [Community Forum](https://forum.bagisto.com)
+
+## 📝 License
+
+The Bagisto API Platform is open-source software licensed under the [MIT license](LICENSE).
+
+ 

+ 34 - 0
packages/Webkul/BagistoApi/composer.json

@@ -0,0 +1,34 @@
+{
+  "name": "bagisto/bagisto-api",
+  "description": "Bagisto API Platform package with GraphQL and REST API support",
+  "type": "laravel-package",
+  "license": "MIT",
+  "authors": [
+    {
+      "name": "Bagisto",
+      "email": "support@bagisto.com"
+    }
+  ],
+  "require": {
+    "api-platform/laravel": "v4.1.25",
+    "api-platform/graphql": "v4.2.3"
+  },
+  "autoload": {
+    "psr-4": {
+      "Webkul\\BagistoApi\\": "src/"
+    }
+  },
+  "extra": {
+    "laravel": {
+      "providers": [
+        "Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider"
+      ]
+    }
+  },
+  "scripts": {
+    "post-autoload-dump": [
+      "@php artisan package:discover --ansi"
+    ]
+  }
+}
+

+ 228 - 0
packages/Webkul/BagistoApi/config/api-platform-vendor.php

@@ -0,0 +1,228 @@
+<?php
+
+/*
+ * This file is part of the API Platform project.
+ *
+ * (c) Kévin Dunglas <dunglas@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+use ApiPlatform\Metadata\Operation\DashPathSegmentNameGenerator;
+use ApiPlatform\Metadata\UrlGeneratorInterface;
+use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Auth\AuthenticationException;
+use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter;
+use Webkul\BagistoApi\Exception\InvalidInputException;
+use Webkul\BagistoApi\Exception\ValidationException;
+
+return [
+    'title'       => '',
+    'description' => '',
+    'version'     => '1.0.0',
+    'show_webby'  => true,
+
+    'routes' => [
+        'domain' => null,
+        // Global middleware applied to every API Platform routes
+        // HandleInvalidInputException: Catches validation errors and returns RFC 7807 format
+        // VerifyStorefrontKey: Validates X-STOREFRONT-KEY header and rate limiting for shop APIs
+        // BagistoApiDocumentationMiddleware: Handles custom /api index and documentation pages
+        // ForceApiJson: Ensures API responses have JSON content-type
+        // CacheResponse: Using custom ApiAwareResponseCache profile that:
+        // - Excludes API routes from caching (APIs need fresh data)
+        // - Caches shop pages for performance
+        // - Only caches HTML, not JSON responses
+        'middleware' => [
+            'Webkul\BagistoApi\Http\Middleware\HandleInvalidInputException',
+            'Webkul\BagistoApi\Http\Middleware\SecurityHeaders',
+            'Webkul\BagistoApi\Http\Middleware\LogApiRequests',
+            'Webkul\BagistoApi\Http\Middleware\VerifyStorefrontKey',
+            'Webkul\BagistoApi\Http\Middleware\BagistoApiDocumentationMiddleware',
+            'Webkul\BagistoApi\Http\Middleware\ForceApiJson',
+            'Spatie\ResponseCache\Middlewares\CacheResponse',
+        ],
+    ],
+
+    'resources' => [
+        base_path('vendor/bagisto/bagisto-api/src/Models/')
+    ],
+
+    'formats' => [
+        'json'=> ['application/json'],
+    ],
+
+    'patch_formats' => [
+        'json' => ['application/merge-patch+json'],
+    ],
+
+    'docs_formats' => [
+        'jsonopenapi' => ['application/vnd.openapi+json'],
+        'html'        => ['text/html'],
+    ],
+
+    'error_formats' => [
+        'jsonproblem' => ['application/problem+json'],
+    ],
+
+    'defaults' => [
+        'pagination_enabled'                => true,
+        'pagination_partial'                => false,
+        'pagination_client_enabled'         => false,
+        'pagination_client_items_per_page'  => false,
+        'pagination_client_partial'         => false,
+        'pagination_items_per_page'         => 10,
+        'pagination_maximum_items_per_page' => 50,
+        'route_prefix'                      => '/api',
+        'middleware'                        => [],
+    ],
+
+    'pagination' => [
+        'page_parameter_name'           => 'page',
+        'enabled_parameter_name'        => 'pagination',
+        'items_per_page_parameter_name' => 'itemsPerPage',
+        'partial_parameter_name'        => 'partial',
+    ],
+
+    'graphql' => [
+        'enabled'              => true,
+        'nesting_separator'    => '__',
+        'introspection'        => ['enabled' => true],
+        'max_query_complexity' => 400,
+        'max_query_depth'      => 20,
+        'graphiql'             => [
+            'enabled'           => true,
+            'default_query'     => null,
+            'default_variables' => null,
+        ],
+        'graphql_playground' => [
+            'enabled'           => true,
+            'default_query'     => null,
+            'default_variables' => null,
+        ],
+        // GraphQL middleware for authentication and rate limiting
+        'middleware' => [
+            'Webkul\BagistoApi\Http\Middleware\VerifyGraphQLStorefrontKey',
+        ],
+    ],
+
+    'graphiql' => [
+        'enabled' => true,
+    ],
+
+    'name_converter' => SnakeCaseToCamelCaseNameConverter::class,
+
+    'path_segment_name_generator' => DashPathSegmentNameGenerator::class,
+
+    'exception_to_status' => [
+        AuthenticationException::class => 401,
+        AuthorizationException::class  => 403,
+        ValidationException::class     => 400,
+        InvalidInputException::class   => 400,
+    ],
+
+    'swagger_ui' => [
+        'enabled' => true,
+        'apiKeys' => [
+            'api' => [
+                'name'   => 'Authorization',
+                'type'   => 'header',
+                'scheme' => 'bearer',
+            ],
+        ],
+    ],
+
+    'url_generation_strategy' => UrlGeneratorInterface::ABS_PATH,
+
+    'serializer' => [
+        'hydra_prefix'    => false,
+        'datetime_format' => 'Y-m-d\TH:i:sP',
+    ],
+
+    'cache' => 'redis',
+
+    'schema_cache' => [
+        'enabled' => true,
+        'store'   => 'redis',
+    ],
+
+    'security' => [
+        'sanctum' => true,
+    ],
+
+    'rate_limit' => [
+        'skip_localhost' => env('RATE_LIMIT_SKIP_LOCALHOST', true),
+        'auth'           => env('RATE_LIMIT_AUTH', 5),
+        'admin'          => env('RATE_LIMIT_ADMIN', 60),
+        'shop'           => env('RATE_LIMIT_SHOP', 100),
+        'graphql'        => env('RATE_LIMIT_GRAPHQL', 100),
+        'cache_driver'   => env('RATE_LIMIT_CACHE', 'redis'),
+        'cache_prefix'   => 'api:rate-limit:',
+    ],
+
+    'security_headers' => [
+        'enabled'     => true,
+        'force_https' => env('APP_FORCE_HTTPS', false),
+        'csp_header'  => "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
+    ],
+
+    'api_logging' => [
+        'enabled'            => env('API_LOG_ENABLED', true),
+        'log_sensitive_data' => env('API_LOG_SENSITIVE_DATA', false),
+        'exclude_paths'      => ['docs', 'graphiql', 'swagger-ui', 'docs.json'],
+        'channel'            => 'api',
+        'async'              => env('API_LOG_ASYNC', true),
+        'queue'              => env('API_LOG_QUEUE', 'api-logs'),
+    ],
+
+    'graphql_validation' => [
+        'max_depth'      => env('GRAPHQL_MAX_DEPTH', 10),
+        'max_complexity' => env('GRAPHQL_MAX_COMPLEXITY', 300),
+    ],
+
+    'request_limits' => [
+        'max_size_mb'          => env('MAX_REQUEST_SIZE', 10),
+        'max_pagination_limit' => env('MAX_PAGINATION_LIMIT', 100),
+    ],
+
+    'database' => [
+        'log_queries'          => env('DB_QUERY_LOG_ENABLED', false),
+        'slow_query_threshold' => env('DB_SLOW_QUERY_THRESHOLD', 1000),
+    ],
+
+    'caching' => [
+        'enable_security_cache' => env('API_SECURITY_CACHE', true),
+        'security_cache_ttl'    => env('API_SECURITY_CACHE_TTL', 3600),
+        'enable_response_cache' => env('API_RESPONSE_CACHE', true),
+        'response_cache_ttl'    => env('API_RESPONSE_CACHE_TTL', 3600),
+    ],
+
+    'http_cache' => [
+        'etag'                   => true,
+        'max_age'                => 3600,
+        'shared_max_age'         => null,
+        'vary'                   => null,
+        'public'                 => true,
+        'stale_while_revalidate' => 30,
+        'stale_if_error'         => null,
+        'invalidation'           => [
+            'urls'              => [],
+            'scoped_clients'    => [],
+            'max_header_length' => 7500,
+            'request_options'   => [],
+            'purger'            => ApiPlatform\HttpCache\SouinPurger::class,
+        ],
+    ],
+
+    'key_rotation_policy' => [
+        'enabled'               => true,
+        'expiration_months'     => env('API_KEY_EXPIRATION_MONTHS', 12),
+        'transition_days'       => env('API_KEY_TRANSITION_DAYS', 7),
+        'cleanup_days'          => env('API_KEY_CLEANUP_DAYS', 90),
+        'cache_ttl'             => env('API_KEY_CACHE_TTL', 3600),
+        'storefront_key_prefix' => env('STOREFRONT_KEY_PREFIX', 'pk_storefront_'),
+    ],
+];

+ 230 - 0
packages/Webkul/BagistoApi/config/api-platform.php

@@ -0,0 +1,230 @@
+<?php
+
+/*
+ * This file is part of the API Platform project.
+ *
+ * (c) Kévin Dunglas <dunglas@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+declare(strict_types=1);
+
+use ApiPlatform\Metadata\Operation\DashPathSegmentNameGenerator;
+use ApiPlatform\Metadata\UrlGeneratorInterface;
+use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Auth\AuthenticationException;
+use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter;
+use Webkul\BagistoApi\Exception\InvalidInputException;
+use Webkul\BagistoApi\Exception\ValidationException;
+
+return [
+    'title'       => '',
+    'description' => '',
+    'version'     => '1.0.0',
+    'show_webby'  => true,
+
+    'routes' => [
+        'domain' => null,
+        // Global middleware applied to every API Platform routes
+        // HandleInvalidInputException: Catches validation errors and returns RFC 7807 format
+        // VerifyStorefrontKey: Validates X-STOREFRONT-KEY header and rate limiting for shop APIs
+        // BagistoApiDocumentationMiddleware: Handles custom /api index and documentation pages
+        // ForceApiJson: Ensures API responses have JSON content-type
+        // CacheResponse: Using custom ApiAwareResponseCache profile that:
+        // - Excludes API routes from caching (APIs need fresh data)
+        // - Caches shop pages for performance
+        // - Only caches HTML, not JSON responses
+        'middleware' => [
+            'Webkul\BagistoApi\Http\Middleware\HandleInvalidInputException',
+            'Webkul\BagistoApi\Http\Middleware\SecurityHeaders',
+            'Webkul\BagistoApi\Http\Middleware\LogApiRequests',
+            'Webkul\BagistoApi\Http\Middleware\SetLocaleChannel',
+            'Webkul\BagistoApi\Http\Middleware\VerifyStorefrontKey',
+            'Webkul\BagistoApi\Http\Middleware\BagistoApiDocumentationMiddleware',
+            'Webkul\BagistoApi\Http\Middleware\ForceApiJson',
+            'Spatie\ResponseCache\Middlewares\CacheResponse',
+        ],
+    ],
+
+    'resources' => [
+        base_path('packages/Webkul/BagistoApi/src/Models/'),
+    ],
+
+    'formats' => [
+        'json'=> ['application/json'],
+    ],
+
+    'patch_formats' => [
+        'json' => ['application/merge-patch+json'],
+    ],
+
+    'docs_formats' => [
+        'jsonopenapi' => ['application/vnd.openapi+json'],
+        'html'        => ['text/html'],
+    ],
+
+    'error_formats' => [
+        'jsonproblem' => ['application/problem+json'],
+    ],
+
+    'defaults' => [
+        'pagination_enabled'                => true,
+        'pagination_partial'                => false,
+        'pagination_client_enabled'         => false,
+        'pagination_client_items_per_page'  => false,
+        'pagination_client_partial'         => false,
+        'pagination_items_per_page'         => 10,
+        'pagination_maximum_items_per_page' => 50,
+        'route_prefix'                      => '/api',
+        'middleware'                        => [],
+    ],
+
+    'pagination' => [
+        'page_parameter_name'           => 'page',
+        'enabled_parameter_name'        => 'pagination',
+        'items_per_page_parameter_name' => 'itemsPerPage',
+        'partial_parameter_name'        => 'partial',
+    ],
+
+    'graphql' => [
+        'enabled'              => true,
+        'nesting_separator'    => '__',
+        'introspection'        => ['enabled' => true],
+        'max_query_complexity' => 400,
+        'max_query_depth'      => 20,
+        'graphiql'             => [
+            'enabled'           => true,
+            'default_query'     => null,
+            'default_variables' => null,
+        ],
+        'graphql_playground' => [
+            'enabled'           => true,
+            'default_query'     => null,
+            'default_variables' => null,
+        ],
+        // GraphQL middleware for authentication and rate limiting
+        'middleware' => [
+            'Webkul\BagistoApi\Http\Middleware\SetLocaleChannel',
+            'Webkul\BagistoApi\Http\Middleware\VerifyGraphQLStorefrontKey',
+        ],
+    ],
+
+    'graphiql' => [
+        'enabled' => true,
+    ],
+
+    'name_converter' => SnakeCaseToCamelCaseNameConverter::class,
+
+    'path_segment_name_generator' => DashPathSegmentNameGenerator::class,
+
+    'exception_to_status' => [
+        AuthenticationException::class => 401,
+        AuthorizationException::class  => 403,
+        ValidationException::class     => 400,
+        InvalidInputException::class   => 400,
+    ],
+
+    'swagger_ui' => [
+        'enabled' => true,
+        'apiKeys' => [
+            'api' => [
+                'name'   => 'Authorization',
+                'type'   => 'header',
+                'scheme' => 'bearer',
+            ],
+        ],
+    ],
+
+    'url_generation_strategy' => UrlGeneratorInterface::ABS_PATH,
+
+    'serializer' => [
+        'hydra_prefix'    => false,
+        'datetime_format' => 'Y-m-d\TH:i:sP',
+    ],
+
+    'cache' => 'redis',
+
+    'schema_cache' => [
+        'enabled' => true,
+        'store'   => 'redis',
+    ],
+
+    'security' => [
+        'sanctum' => true,
+    ],
+
+    'rate_limit' => [
+        'skip_localhost' => env('RATE_LIMIT_SKIP_LOCALHOST', true),
+        'auth'           => env('RATE_LIMIT_AUTH', 5),
+        'admin'          => env('RATE_LIMIT_ADMIN', 60),
+        'shop'           => env('RATE_LIMIT_SHOP', 100),
+        'graphql'        => env('RATE_LIMIT_GRAPHQL', 100),
+        'cache_driver'   => env('RATE_LIMIT_CACHE', 'redis'),
+        'cache_prefix'   => 'api:rate-limit:',
+    ],
+
+    'security_headers' => [
+        'enabled'     => true,
+        'force_https' => env('APP_FORCE_HTTPS', false),
+        'csp_header'  => "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'",
+    ],
+
+    'api_logging' => [
+        'enabled'            => env('API_LOG_ENABLED', true),
+        'log_sensitive_data' => env('API_LOG_SENSITIVE_DATA', false),
+        'exclude_paths'      => ['docs', 'graphiql', 'swagger-ui', 'docs.json'],
+        'channel'            => 'api',
+        'async'              => env('API_LOG_ASYNC', true),
+        'queue'              => env('API_LOG_QUEUE', 'api-logs'),
+    ],
+
+    'graphql_validation' => [
+        'max_depth'      => env('GRAPHQL_MAX_DEPTH', 10),
+        'max_complexity' => env('GRAPHQL_MAX_COMPLEXITY', 300),
+    ],
+
+    'request_limits' => [
+        'max_size_mb'          => env('MAX_REQUEST_SIZE', 10),
+        'max_pagination_limit' => env('MAX_PAGINATION_LIMIT', 100),
+    ],
+
+    'database' => [
+        'log_queries'          => env('DB_QUERY_LOG_ENABLED', false),
+        'slow_query_threshold' => env('DB_SLOW_QUERY_THRESHOLD', 1000),
+    ],
+
+    'caching' => [
+        'enable_security_cache' => env('API_SECURITY_CACHE', true),
+        'security_cache_ttl'    => env('API_SECURITY_CACHE_TTL', 3600),
+        'enable_response_cache' => env('API_RESPONSE_CACHE', true),
+        'response_cache_ttl'    => env('API_RESPONSE_CACHE_TTL', 3600),
+    ],
+
+    'http_cache' => [
+        'etag'                   => true,
+        'max_age'                => 3600,
+        'shared_max_age'         => null,
+        'vary'                   => null,
+        'public'                 => true,
+        'stale_while_revalidate' => 30,
+        'stale_if_error'         => null,
+        'invalidation'           => [
+            'urls'              => [],
+            'scoped_clients'    => [],
+            'max_header_length' => 7500,
+            'request_options'   => [],
+            'purger'            => ApiPlatform\HttpCache\SouinPurger::class,
+        ],
+    ],
+
+    'key_rotation_policy' => [
+        'enabled'               => true,
+        'expiration_months'     => env('API_KEY_EXPIRATION_MONTHS', 12),
+        'transition_days'       => env('API_KEY_TRANSITION_DAYS', 7),
+        'cleanup_days'          => env('API_KEY_CLEANUP_DAYS', 90),
+        'cache_ttl'             => env('API_KEY_CACHE_TTL', 3600),
+        'storefront_key_prefix' => env('STOREFRONT_KEY_PREFIX', 'pk_storefront_'),
+    ],
+];

+ 78 - 0
packages/Webkul/BagistoApi/config/graphql-auth.php

@@ -0,0 +1,78 @@
+<?php
+
+/**
+ * GraphQL Authentication Configuration
+ *
+ * Defines which GraphQL operations require X-STOREFRONT-KEY authentication.
+ * X-STOREFRONT-KEY is the generic, header for all client types:
+ * - Mobile apps
+ * - Web storefronts
+ * - Headless commerce
+ * - Admin dashboards
+ * - Third-party integrations *
+ */
+
+return [
+    /**
+     * Public GraphQL operations that don't require X-STOREFRONT-KEY header
+     *
+     * AUTHENTICATION STRATEGY:
+     * - X-STOREFRONT-KEY: ALWAYS required (identifies client/app)
+     * - Bearer Token: Required only for user-specific operations
+     *
+     * All operations require X-STOREFRONT-KEY. Then:
+     * - Public operations: X-STOREFRONT-KEY only
+     * - User operations: X-STOREFRONT-KEY + Bearer token (Sanctum)
+     * - Admin operations: X-STOREFRONT-KEY + Admin Bearer token
+     *
+     * Empty list = All operations require X-STOREFRONT-KEY
+     */
+    'public_operations' => [
+        '__schema',
+        '__type',
+    ],
+
+    /**
+     * Protected operations that require X-STOREFRONT-KEY header
+     *
+     * Leave this as an empty array to use blacklist approach (recommended).
+     * If you list operations here, they WILL require authentication.
+     * Unlisted operations with this non-empty array will NOT require auth.
+     *
+     * BEST PRACTICE: Keep this empty and use public_operations instead
+     * This way: protected_operations = everything NOT in public_operations
+     */
+    'protected_operations' => [
+    ],
+
+    /**
+     * Enable selective authentication
+     *
+     * true:  Use whitelist approach (public_operations)
+     * false: Use blacklist approach (protected_operations)
+     *
+     * RECOMMENDED: true (whitelist is more secure)
+     */
+    'use_whitelist' => true,
+
+    /**
+     * Skip authentication for introspection queries
+     * Allow GraphQL tools and playground to inspect schema without key
+     */
+    'allow_introspection' => true,
+
+    /**
+     * Detailed logging for authentication
+     * Set to 'true' to log all authentication checks
+     */
+    'log_auth_checks' => env('GRAPHQL_AUTH_LOG', false),
+
+    /**
+     * Custom error messages
+     */
+    'messages' => [
+        'missing_key' => 'X-STOREFRONT-KEY header is required for this operation',
+        'invalid_key' => 'Invalid or expired API key',
+        'rate_limit'  => 'Rate limit exceeded. Please try again later.',
+    ],
+];

+ 54 - 0
packages/Webkul/BagistoApi/config/storefront.php

@@ -0,0 +1,54 @@
+<?php
+
+/**
+ * Storefront API Key Configuration
+ *
+ * Settings for X-STOREFRONT-KEY authentication for shop/storefront APIs
+ */
+
+return [
+    /*
+    |--------------------------------------------------------------------------
+    | Default Rate Limit
+    |--------------------------------------------------------------------------
+    |
+    | Default number of requests allowed per minute for each storefront key.
+    | Can be overridden per key in the database.
+    |
+    */
+    'default_rate_limit' => env('STOREFRONT_DEFAULT_RATE_LIMIT', 100),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Cache TTL
+    |--------------------------------------------------------------------------
+    |
+    | Time-to-live for cached key validation results in minutes.
+    | Reduces database queries for repeated requests using the same key.
+    |
+    */
+    'cache_ttl' => env('STOREFRONT_CACHE_TTL', 60),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Cache Key Prefix
+    |--------------------------------------------------------------------------
+    |
+    | Prefix used for cache keys to avoid collisions with other cache entries.
+    |
+    */
+    'key_prefix' => env('STOREFRONT_KEY_PREFIX', 'storefront_key_'),
+
+    /*
+    |--------------------------------------------------------------------------
+    | Playground API Key
+    |--------------------------------------------------------------------------
+    |
+    | API key used for API documentation and GraphQL playground.
+    | Generate a dedicated key and set it in your .env file.
+    |
+    | Example: STOREFRONT_PLAYGROUND_KEY=pk_storefront_xxx
+    |
+    */
+    'playground_key' => env('STOREFRONT_PLAYGROUND_KEY'),
+];

+ 19 - 0
packages/Webkul/BagistoApi/src/Attributes/AllowPublic.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Webkul\BagistoApi\Attributes;
+
+use Attribute;
+
+/**
+ * Mark a GraphQL operation as public (no X-STOREFRONT-KEY required)
+ *
+ * @see RequiresStorefrontKey
+ */
+#[Attribute(Attribute::TARGET_METHOD)]
+class AllowPublic
+{
+    public function __construct(
+        public ?string $description = null,
+        public bool $rateLimitByIp = false,
+    ) {}
+}

+ 19 - 0
packages/Webkul/BagistoApi/src/Attributes/RequiresStorefrontKey.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Webkul\BagistoApi\Attributes;
+
+use Attribute;
+
+/**
+ * Mark a GraphQL operation as requiring X-STOREFRONT-KEY authentication
+ *
+ * @see AllowPublic
+ */
+#[Attribute(Attribute::TARGET_METHOD)]
+class RequiresStorefrontKey
+{
+    public function __construct(
+        public ?string $message = null,
+        public ?string $description = null,
+    ) {}
+}

+ 113 - 0
packages/Webkul/BagistoApi/src/CacheProfiles/ApiAwareResponseCache.php

@@ -0,0 +1,113 @@
+<?php
+
+namespace Webkul\BagistoApi\CacheProfiles;
+
+use Illuminate\Http\Request;
+use Spatie\ResponseCache\CacheProfiles\CacheProfile;
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * ApiAwareResponseCache Profile
+ *
+ * This cache profile:
+ * 1. Excludes ALL API routes from caching (API should return fresh data)
+ * 2. Caches shop/storefront pages for performance
+ * 3. Only caches successful (200) responses
+ * 4. Respects cache bypass headers
+ *
+ * Benefits:
+ * - APIs always return fresh data with correct content-type
+ * - Shop pages are cached for speed
+ * - No HTML cached for API responses
+ */
+class ApiAwareResponseCache implements CacheProfile
+{
+    /**
+     * Determine if the response cache middleware is enabled
+     */
+    public function enabled(Request $request): bool
+    {
+        return config('responsecache.enabled', false);
+    }
+
+    /**
+     * Determine if the request should be cached.
+     */
+    public function shouldCacheRequest(Request $request): bool
+    {
+        // Don't cache API routes - they need fresh data
+        if ($request->is('api/*') || $request->is('graphql*')) {
+            return false;
+        }
+
+        // Don't cache non-GET requests
+        if (! $request->isMethod('GET')) {
+            return false;
+        }
+
+        // Don't cache requests with query parameters (search, filters, pagination)
+        if ($request->getQueryString()) {
+            return false;
+        }
+
+        // Don't cache if user is authenticated (personalized content)
+        if ($request->user()) {
+            return false;
+        }
+
+        // Cache only shop pages (storefront)
+        if ($request->is('shop/*') ||
+            $request->is('categories/*') ||
+            $request->is('products/*') ||
+            $request->is('*') && ! $request->is('admin/*')) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Determine if the response should be cached.
+     *
+     * Only cache successful (200) HTML responses
+     */
+    public function shouldCacheResponse(Response $response): bool
+    {
+        // Only cache successful responses
+        if ($response->getStatusCode() !== 200) {
+            return false;
+        }
+
+        // Only cache HTML responses (not JSON or other formats)
+        $contentType = $response->headers->get('Content-Type', '');
+        if (strpos($contentType, 'text/html') === false) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Return the tags to use for this cached response.
+     */
+    public function cacheNameSuffix(Request $request): string
+    {
+        return '';
+    }
+
+    /**
+     * Return until when the response must be cached.
+     */
+    public function cacheRequestUntil(Request $request): \DateTime
+    {
+        return now()->addDay();
+    }
+
+    /**
+     * Determine if cache name suffix should be used
+     */
+    public function useCacheNameSuffix(Request $request): string
+    {
+        return '';
+    }
+}

+ 141 - 0
packages/Webkul/BagistoApi/src/Console/Commands/ApiKeyMaintenanceCommand.php

@@ -0,0 +1,141 @@
+<?php
+
+namespace Webkul\BagistoApi\Console\Commands;
+
+use Illuminate\Console\Command;
+use Webkul\BagistoApi\Services\KeyRotationService;
+
+/**
+ * Perform automatic API key maintenance tasks
+ */
+class ApiKeyMaintenanceCommand extends Command
+{
+    protected $signature = 'bagisto-api:key:maintain 
+                            {--cleanup : Clean up expired keys}
+                            {--invalidate : Invalidate deprecated keys}
+                            {--notify : Send expiration notifications}
+                            {--all : Perform all maintenance tasks}';
+
+    protected $description = 'Automatic API key maintenance (cleanup, deprecation, notifications)';
+
+    protected KeyRotationService $rotationService;
+
+    public function __construct()
+    {
+        parent::__construct();
+        $this->rotationService = new KeyRotationService;
+    }
+
+    /**
+     * Execute the maintenance command.
+     */
+    public function handle(): int
+    {
+        $cleanup = $this->option('cleanup') || $this->option('all');
+        $invalidate = $this->option('invalidate') || $this->option('all');
+        $notify = $this->option('notify') || $this->option('all');
+
+        if (! $cleanup && ! $invalidate && ! $notify) {
+            $cleanup = $invalidate = $notify = true;
+        }
+
+        $this->info(__('bagistoapi::app.graphql.install.maintenance-starting'));
+        $this->newLine();
+
+        if ($cleanup) {
+            $this->cleanup();
+        }
+
+        if ($invalidate) {
+            $this->invalidateDeprecatedKeys();
+        }
+
+        if ($notify) {
+            $this->notifyExpiringKeys();
+        }
+
+        $this->newLine();
+        $this->info(__('bagistoapi::app.graphql.install.maintenance-complete'));
+
+        return 0;
+    }
+
+    /**
+     * Clean up expired keys.
+     */
+    private function cleanup(): void
+    {
+        $this->line(__('bagistoapi::app.graphql.install.cleanup-expired-keys'));
+
+        $count = $this->rotationService->cleanupExpiredKeys();
+
+        if ($count > 0) {
+            $this->info(__('bagistoapi::app.graphql.install.cleanup-success-message', ['count' => $count]));
+        } else {
+            $this->line(__('bagistoapi::app.graphql.install.cleanup-expired-none'));
+        }
+    }
+
+    /**
+     * Invalidate deprecated keys.
+     */
+    private function invalidateDeprecatedKeys(): void
+    {
+        $this->line(__('bagistoapi::app.graphql.install.invalidate-deprecated'));
+
+        $count = $this->rotationService->invalidateDeprecatedKeys();
+
+        if ($count > 0) {
+            $this->info(__('bagistoapi::app.graphql.install.invalidate-success-message', ['count' => $count]));
+        } else {
+            $this->line(__('bagistoapi::app.graphql.install.invalidate-deprecated-none'));
+        }
+    }
+
+    /**
+     * Send expiration notifications.
+     */
+    private function notifyExpiringKeys(): void
+    {
+        $this->line(__('bagistoapi::app.graphql.install.notify-expiring'));
+
+        $keysExpiring7Days = $this->rotationService->getKeysExpiringSoon(7);
+        $keysExpiring30Days = $this->rotationService->getKeysExpiringSoon(30);
+
+        $notified = 0;
+
+        foreach ($keysExpiring7Days as $key) {
+            if ($this->sendExpirationNotification($key, '7 days')) {
+                $notified++;
+            }
+        }
+
+        foreach ($keysExpiring30Days as $key) {
+            if (! $keysExpiring7Days->contains($key)) {
+                if ($this->sendExpirationNotification($key, '30 days')) {
+                    $notified++;
+                }
+            }
+        }
+
+        if ($notified > 0) {
+            $this->info(__('bagistoapi::app.graphql.install.notify-success-message', ['count' => $notified]));
+        } else {
+            $this->line(__('bagistoapi::app.graphql.install.notify-expiring-none'));
+        }
+    }
+
+    /**
+     * Send expiration notification for a key.
+     */
+    private function sendExpirationNotification($key, string $timeframe): bool
+    {
+        try {
+            return true;
+        } catch (\Exception $e) {
+            $this->warn(__('bagistoapi::app.graphql.install.notify-failed-message', ['key' => $key->name, 'error' => $e->getMessage()]));
+
+            return false;
+        }
+    }
+}

+ 316 - 0
packages/Webkul/BagistoApi/src/Console/Commands/ApiKeyManagementCommand.php

@@ -0,0 +1,316 @@
+<?php
+
+namespace Webkul\BagistoApi\Console\Commands;
+
+use Illuminate\Console\Command;
+use Webkul\BagistoApi\Models\StorefrontKey;
+use Webkul\BagistoApi\Services\KeyRotationService;
+
+/**
+ * Manage API key rotation, expiration, and lifecycle.
+ */
+class ApiKeyManagementCommand extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'bagisto-api:key:manage
+                            {action : Action to perform (rotate, deactivate, cleanup, status, expiring, unused, summary)}
+                            {--key= : API Key ID or name}
+                            {--reason= : Reason for deactivation}
+                            {--days=7 : Number of days for "expiring" action}
+                            {--unused=90 : Number of days for "unused" action}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Manage API key rotation, expiration, and lifecycle';
+
+    /**
+     * Service instance.
+     */
+    protected KeyRotationService $rotationService;
+
+    /**
+     * Create a new command instance.
+     */
+    public function __construct()
+    {
+        parent::__construct();
+        $this->rotationService = new KeyRotationService;
+    }
+
+    /**
+     * Execute the console command.
+     */
+    public function handle(): int
+    {
+        $action = $this->argument('action');
+
+        return match ($action) {
+            'rotate'     => $this->rotateKey(),
+            'deactivate' => $this->deactivateKey(),
+            'cleanup'    => $this->cleanupExpiredKeys(),
+            'status'     => $this->showKeyStatus(),
+            'expiring'   => $this->showExpiringKeys(),
+            'unused'     => $this->showUnusedKeys(),
+            'summary'    => $this->showPolicySummary(),
+            default      => $this->handleInvalidAction($action),
+        };
+    }
+
+    /**
+     * Rotate an API key.
+     */
+    private function rotateKey(): int
+    {
+        $keyId = $this->option('key');
+        if (! $keyId) {
+            $this->error(__('bagistoapi::app.graphql.install.key-management-required', ['action' => 'rotate']));
+            return 1;
+        }
+
+        try {
+            $key = $this->findKey($keyId);
+        } catch (\Exception $e) {
+            $this->error($e->getMessage());
+            return 1;
+        }
+
+        if (! $key->isValid()) {
+            $this->error(__('bagistoapi::app.graphql.install.key-rotation-error', ['error' => 'invalid key']));
+            return 1;
+        }
+
+        try {
+            $newKey = $this->rotationService->rotateKey($key);
+
+            $this->info(__('bagistoapi::app.graphql.install.key-rotated-success'));
+            $this->line(__('bagistoapi::app.graphql.install.old-key', ['name' => $key->name]));
+            $this->line(__('bagistoapi::app.graphql.install.old-key-id', ['id' => $key->id]));
+            $this->line(__('bagistoapi::app.graphql.install.deprecation-date', ['date' => $key->deprecation_date]));
+            $this->newLine();
+            $this->line(__('bagistoapi::app.graphql.install.new-key', ['name' => $newKey->name]));
+            $this->line(__('bagistoapi::app.graphql.install.new-key-id', ['id' => $newKey->id]));
+            $this->line(__('bagistoapi::app.graphql.install.new-key-value', ['key' => $newKey->key]));
+            $this->line(__('bagistoapi::app.graphql.install.expires-at', ['date' => $newKey->expires_at]));
+
+            return 0;
+        } catch (\Exception $e) {
+            $this->error(__('bagistoapi::app.graphql.install.key-rotation-error', ['error' => $e->getMessage()]));
+            return 1;
+        }
+    }
+
+    /**
+     * Deactivate an API key.
+     */
+    private function deactivateKey(): int
+    {
+        $keyId = $this->option('key');
+        if (! $keyId) {
+            $this->error(__('bagistoapi::app.graphql.install.key-management-required', ['action' => 'deactivate']));
+            return 1;
+        }
+
+        try {
+            $key = $this->findKey($keyId);
+        } catch (\Exception $e) {
+            $this->error($e->getMessage());
+            return 1;
+        }
+
+        $reason = $this->option('reason') ?? 'Manual deactivation';
+
+        if ($this->confirm(__('bagistoapi::app.graphql.install.confirm-deactivate', ['name' => $key->name]))) {
+            try {
+                $this->rotationService->deactivateKey($key, $reason);
+                $this->info(__('bagistoapi::app.graphql.install.key-deactivated-success'));
+                return 0;
+            } catch (\Exception $e) {
+                $this->error(__('bagistoapi::app.graphql.install.key-deactivation-error', ['error' => $e->getMessage()]));
+                return 1;
+            }
+        }
+
+        $this->info(__('bagistoapi::app.graphql.install.deactivation-cancelled'));
+        return 0;
+    }
+
+    /**
+     * Clean up expired keys.
+     */
+    private function cleanupExpiredKeys(): int
+    {
+        if ($this->confirm(__('bagistoapi::app.graphql.install.confirm-cleanup'))) {
+            $count = $this->rotationService->cleanupExpiredKeys();
+            $this->info(__('bagistoapi::app.graphql.install.cleanup-success', ['count' => $count]));
+            return 0;
+        }
+
+        $this->info(__('bagistoapi::app.graphql.install.cleanup-cancelled'));
+        return 0;
+    }
+
+    /**
+     * Show status of a specific key.
+     */
+    private function showKeyStatus(): int
+    {
+        $keyId = $this->option('key');
+        if (! $keyId) {
+            $this->error(__('bagistoapi::app.graphql.install.key-management-required', ['action' => 'status']));
+            return 1;
+        }
+
+        try {
+            $key = $this->findKey($keyId);
+        } catch (\Exception $e) {
+            $this->error($e->getMessage());
+            return 1;
+        }
+
+        $status = $this->rotationService->getRotationStatus($key);
+
+        $this->info(__('bagistoapi::app.graphql.install.key-status-title', ['name' => $key->name]));
+        $this->newLine();
+
+        $this->line(__('bagistoapi::app.graphql.install.key-status-active', ['status' => $status['is_valid'] ? '✅ Yes' : '❌ No']));
+        $this->line(__('bagistoapi::app.graphql.install.key-status-usable', ['status' => $status['is_usable'] ? '✅ Yes' : '❌ No']));
+        $this->line(__('bagistoapi::app.graphql.install.key-status-expired', ['status' => $status['is_expired'] ? '❌ Yes' : '✅ No']));
+        $this->line(__('bagistoapi::app.graphql.install.key-status-deprecated', ['status' => $status['is_deprecated'] ? '⚠️ Yes' : '✅ No']));
+        $this->newLine();
+
+        $expiresAtText = $status['expires_at'] ? $status['expires_at']->format('Y-m-d H:i:s') : __('bagistoapi::app.graphql.install.key-never');
+        $this->line(__('bagistoapi::app.graphql.install.key-status-expires-at', ['expires' => $expiresAtText]));
+
+        $daysText = $status['days_until_expiry'] ? $status['days_until_expiry'].__('bagistoapi::app.graphql.install.key-days') : 'N/A';
+        $this->line(__('bagistoapi::app.graphql.install.key-status-days-until-expiry', ['days' => $daysText]));
+
+        $lastUsedText = $status['last_used_at'] ? $status['last_used_at']->format('Y-m-d H:i:s') : __('bagistoapi::app.graphql.install.key-never');
+        $this->line(__('bagistoapi::app.graphql.install.key-status-last-used', ['date' => $lastUsedText]));
+        $this->newLine();
+
+        if ($status['rotated_from']) {
+            $this->line(__('bagistoapi::app.graphql.install.key-status-rotated-from', ['key' => $status['rotated_from']]));
+        }
+        if ($status['rotated_keys']) {
+            $this->line(__('bagistoapi::app.graphql.install.key-status-rotated-keys', ['count' => $status['rotated_keys']]));
+        }
+
+        return 0;
+    }
+
+    /**
+     * Show keys expiring soon.
+     */
+    private function showExpiringKeys(): int
+    {
+        $days = (int) $this->option('days');
+        $keys = $this->rotationService->getKeysExpiringSoon($days);
+
+        if ($keys->isEmpty()) {
+            $this->info(__('bagistoapi::app.graphql.install.no-keys-expiring', ['days' => $days]));
+            return 0;
+        }
+
+        $this->info(__('bagistoapi::app.graphql.install.keys-expiring-title', ['days' => $days]));
+        $this->newLine();
+
+        foreach ($keys as $key) {
+            $daysLeft = $key->expires_at->diffInDays(now());
+            $this->line(__('bagistoapi::app.graphql.install.key-display-format', ['name' => $key->name, 'id' => $key->id]));
+            $this->line(__('bagistoapi::app.graphql.install.key-expires-display', ['date' => $key->expires_at->format('Y-m-d'), 'days' => $daysLeft]));
+        }
+
+        return 0;
+    }
+
+    /**
+     * Show unused keys.
+     */
+    private function showUnusedKeys(): int
+    {
+        $days = (int) $this->option('unused');
+        $keys = $this->rotationService->getUnusedKeys($days);
+
+        if ($keys->isEmpty()) {
+            $this->info(__('bagistoapi::app.graphql.install.no-unused-keys', ['days' => $days]));
+            return 0;
+        }
+
+        $this->info(__('bagistoapi::app.graphql.install.unused-keys-title', ['days' => $days]));
+        $this->newLine();
+
+        foreach ($keys as $key) {
+            $lastUsed = $key->last_used_at
+                ? $key->last_used_at->format('Y-m-d')
+                : __('bagistoapi::app.graphql.install.key-never');
+            $this->line(__('bagistoapi::app.graphql.install.key-display-format', ['name' => $key->name, 'id' => $key->id]));
+            $this->line(__('bagistoapi::app.graphql.install.key-last-used-display', ['date' => $lastUsed]));
+        }
+
+        return 0;
+    }
+
+    /**
+     * Show policy compliance summary.
+     */
+    private function showPolicySummary(): int
+    {
+        $summary = $this->rotationService->getPolicyComplianceSummary();
+
+        $this->info(__('bagistoapi::app.graphql.install.policy-compliance-summary'));
+        $this->newLine();
+
+        $this->line(__('bagistoapi::app.graphql.install.total-keys', ['count' => $summary['total_active_keys']]));
+        $this->line(__('bagistoapi::app.graphql.install.valid-keys', ['count' => $summary['total_valid_keys']]));
+        $this->line(__('bagistoapi::app.graphql.install.expired-keys', ['count' => $summary['expired_keys']]));
+        $this->line(__('bagistoapi::app.graphql.install.deprecated-keys', ['count' => $summary['deprecated_keys']]));
+        $this->line(__('bagistoapi::app.graphql.install.keys-expiring-soon', ['count' => $summary['keys_expiring_soon']]));
+        $this->line(__('bagistoapi::app.graphql.install.unused-keys-summary', ['count' => $summary['unused_keys']]));
+        $this->line(__('bagistoapi::app.graphql.install.recently-rotated', ['count' => $summary['recently_rotated']]));
+
+        return 0;
+    }
+
+    /**
+     * Handle invalid action.
+     */
+    private function handleInvalidAction(string $action): int
+    {
+        $this->error(__('bagistoapi::app.graphql.install.invalid-action', ['action' => $action]));
+        $this->info(__('bagistoapi::app.graphql.install.available-actions'));
+
+        return 1;
+    }
+
+    /**
+     * Find a key by ID or name.
+     *
+     * @param  string  $keyIdentifier
+     * @return StorefrontKey
+     *
+     * @throws \Exception
+     */
+    private function findKey(string $keyIdentifier): StorefrontKey
+    {
+        if (is_numeric($keyIdentifier)) {
+            $key = StorefrontKey::find($keyIdentifier);
+            if ($key) {
+                return $key;
+            }
+        }
+
+        $key = StorefrontKey::where('name', $keyIdentifier)->first();
+        if ($key) {
+            return $key;
+        }
+
+        throw new \Exception(__('bagistoapi::app.graphql.key-not-found', ['identifier' => $keyIdentifier]));
+    }
+}

+ 45 - 0
packages/Webkul/BagistoApi/src/Console/Commands/ClearApiPlatformCacheCommand.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace Webkul\BagistoApi\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Cache;
+
+class ClearApiPlatformCacheCommand extends Command
+{
+    protected $signature = 'api-platform:clear-cache {--store= : Override cache store name to flush}';
+
+    protected $description = 'Flush API Platform metadata/schema cache store(s) so GraphQL/OpenAPI schema updates are picked up.';
+
+    public function handle(): int
+    {
+        $configuredStore = (string) config('api-platform.cache', 'file');
+        $schemaCacheEnabled = (bool) config('api-platform.schema_cache.enabled', false);
+        $schemaStore = $schemaCacheEnabled
+            ? (string) config('api-platform.schema_cache.store', $configuredStore)
+            : $configuredStore;
+
+        $overrideStore = $this->option('store');
+        if (\is_string($overrideStore) && $overrideStore !== '') {
+            $configuredStore = $overrideStore;
+            $schemaStore = $overrideStore;
+        }
+
+        $stores = array_values(array_unique(array_filter([$configuredStore, $schemaStore])));
+
+        foreach ($stores as $store) {
+            try {
+                Cache::store($store)->flush();
+                $this->info(sprintf('Flushed cache store "%s".', $store));
+            } catch (\Throwable $e) {
+                $this->error(sprintf('Failed to flush cache store "%s": %s', $store, $e->getMessage()));
+
+                return self::FAILURE;
+            }
+        }
+
+        $this->line('If you are running PHP-FPM with OPcache in production, you may also need to restart PHP-FPM.');
+
+        return self::SUCCESS;
+    }
+}

+ 81 - 0
packages/Webkul/BagistoApi/src/Console/Commands/GenerateStorefrontKey.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace Webkul\BagistoApi\Console\Commands;
+
+use Illuminate\Console\Command;
+use Webkul\BagistoApi\Models\StorefrontKey;
+
+/**
+ * Generate a new storefront API key for shop/storefront API authentication
+ */
+class GenerateStorefrontKey extends Command
+{
+    /**
+     * Maximum allowed rate limit (requests per minute).
+     */
+    protected const MAX_RATE_LIMIT = 5000;
+    protected $signature = 'bagisto-api:generate-key
+                            {--name= : Name of the storefront key}
+                            {--rate-limit=100 : Rate limit (requests per minute, leave empty for unlimited)}
+                            {--no-activation : Create the key in inactive state}';
+
+    protected $description = 'Generate a new storefront API key for shop/storefront APIs';
+
+    /**
+     * Execute the command.
+     */
+    public function handle(): int
+    {
+        $name = $this->option('name') ?? $this->ask('Enter the name for this storefront key');
+
+        if (empty($name)) {
+            $this->error(__('bagistoapi::app.graphql.install.key-name-required'));
+
+            return self::FAILURE;
+        }
+
+        if (StorefrontKey::where('name', $name)->exists()) {
+            $this->error(__('bagistoapi::app.graphql.install.key-already-exists', ['name' => $name]));
+
+            return self::FAILURE;
+        }
+
+        $rateLimitOption = $this->option('rate-limit');
+        
+        // Handle rate limit: null/empty = unlimited (up to MAX), number = capped at MAX
+        if ($rateLimitOption === '' || $rateLimitOption === null) {
+            $rateLimit = null; // null means unlimited in database
+        } else {
+            $requestedLimit = (int) $rateLimitOption;
+            if ($requestedLimit > self::MAX_RATE_LIMIT) {
+                $this->warn(__('bagistoapi::app.graphql.install.rate-limit-exceeded', ['max' => self::MAX_RATE_LIMIT]));
+                $rateLimit = self::MAX_RATE_LIMIT;
+            } else {
+                $rateLimit = $requestedLimit;
+            }
+        }
+        
+        $key = StorefrontKey::generateKey();
+        $storefront = StorefrontKey::create([
+            'name'       => $name,
+            'key'        => $key,
+            'is_active'  => ! $this->option('no-activation'),
+            'rate_limit' => $rateLimit,
+        ]);
+
+        $this->info(__('bagistoapi::app.graphql.install.key-generated-success'));
+        $this->newLine();
+        $this->line('<info>'.__('bagistoapi::app.graphql.install.key-details').'</info>');
+        $this->line("  <fg=cyan>".__('bagistoapi::app.graphql.install.key-field-id')."</> : {$storefront->id}");
+        $this->line("  <fg=cyan>".__('bagistoapi::app.graphql.install.key-field-name')."</> : {$storefront->name}");
+        $this->line("  <fg=cyan>".__('bagistoapi::app.graphql.install.key-field-key')."</> : <fg=yellow>{$key}</>");
+        $rateLimitDisplay = $rateLimit ? $rateLimit.__('bagistoapi::app.graphql.install.key-requests-minute') : __('bagistoapi::app.graphql.install.key-unlimited');
+        $this->line("  <fg=cyan>".__('bagistoapi::app.graphql.install.key-field-rate-limit')."</> : {$rateLimitDisplay}");
+        $this->line('  <fg=cyan>'.__('bagistoapi::app.graphql.install.key-field-status').'</> : '.($storefront->is_active ? '<fg=green>Active</>' : '<fg=red>Inactive</>'));
+        $this->newLine();
+        $this->warn(__('bagistoapi::app.graphql.install.key-secure-warning'));
+        $this->warn(__('bagistoapi::app.graphql.install.key-share-warning'));
+
+        return self::SUCCESS;
+    }
+}

+ 504 - 0
packages/Webkul/BagistoApi/src/Console/Commands/InstallApiPlatformCommand.php

@@ -0,0 +1,504 @@
+<?php
+
+namespace Webkul\BagistoApi\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Filesystem\Filesystem;
+use Symfony\Component\Process\Process;
+
+class InstallApiPlatformCommand extends Command
+{
+    protected $signature = 'bagisto-api-platform:install';
+
+    protected $description = 'Install and configure API Platform for Bagisto';
+
+    public function __construct(protected Filesystem $files)
+    {
+        parent::__construct();
+    }
+
+    /**
+     * Execute the installation process.
+     */
+    public function handle(): int
+    {
+        $this->info(__('bagistoapi::app.graphql.install.starting'));
+
+        try {
+            $this->publishPackageAssets();
+
+            $this->registerServiceProvider();
+
+            $this->linkApiPlatformAssets();
+
+            $this->updateComposerAutoload();
+
+            $this->makeTranslatableModelAbstract();
+
+            $this->registerApiPlatformProviders();
+
+            $this->runDatabaseMigrations();
+
+            $this->clearAndOptimizeCaches();
+
+            $this->generateApiKey();
+
+            $this->publishConfiguration();
+
+            $this->clearAndOptimizeCaches();
+
+            $this->info(__('bagistoapi::app.graphql.install.completed-success'));
+            $this->newLine();
+
+            $appUrl = config('app.url');
+
+            $this->newLine();
+            $this->info(__('bagistoapi::app.graphql.install.api-endpoints'));
+            $this->line(__('bagistoapi::app.graphql.install.api-documentation', ['url' => 'https://api-docs.bagisto.com/']));
+            $this->line(__('bagistoapi::app.graphql.install.api-landing-page', ['url' => "{$appUrl}/api"]));
+            $this->line(__('bagistoapi::app.graphql.install.graphql-playground', ['url' => "{$appUrl}/api/graphiql"]));
+            $this->line(__('bagistoapi::app.graphql.install.rest-api-storefront', ['url' => "{$appUrl}/api/shop"]));
+            $this->line(__('bagistoapi::app.graphql.install.rest-api-admin', ['url' => "{$appUrl}/api/admin"]));
+
+            $this->newLine();
+            $this->info(__('bagistoapi::app.graphql.install.completed-info'));
+
+            return self::SUCCESS;
+        } catch (\Exception $e) {
+            $this->error(__('bagistoapi::app.graphql.install.failed').$e->getMessage());
+
+            return self::FAILURE;
+        }
+    }
+
+    /**
+     * Register the API Platform service provider.
+     */
+    protected function registerServiceProvider(): void
+    {
+        $providersPath = base_path('bootstrap/providers.php');
+
+        if (! $this->files->exists($providersPath)) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.provider-file-not-found', ['file' => $providersPath]));
+        }
+
+        if (! is_writable($providersPath)) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.provider-permission-denied', ['file' => $providersPath]));
+        }
+
+        $content = $this->files->get($providersPath);
+
+        $providerClass = 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider::class';
+
+        if (strpos($content, $providerClass) !== false) {
+            $this->comment(__('bagistoapi::app.graphql.install.provider-already-registered'));
+
+            return;
+        }
+
+        $content = preg_replace(
+            '/(\],\s*\);)/',
+            "    $providerClass,\n$1",
+            $content
+        );
+
+        $this->files->put($providersPath, $content);
+
+        $this->line(__('bagistoapi::app.graphql.install.provider-registered'));
+    }
+
+    /**
+     * Publish the API Platform configuration file.
+     */
+    protected function publishConfiguration(): void
+    {
+        $source = __DIR__.'/../../../config/api-platform.php';
+        $vendorSource = __DIR__.'/../../../config/api-platform-vendor.php';
+
+        $destination = config_path('api-platform.php');
+
+        if ($this->files->exists(base_path('vendor/bagisto/bagisto-api/config/api-platform-vendor.php'))) {
+            $source = $vendorSource;
+        }
+
+        if (! $this->files->exists($source)) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.config-source-not-found', ['file' => $source]));
+        }
+
+        if ($this->files->exists($destination)) {
+            $this->comment(__('bagistoapi::app.graphql.install.config-already-exists'));
+
+            return;
+        }
+
+        $configDir = dirname($destination);
+        if (! is_writable($configDir)) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.config-permission-denied', ['directory' => $configDir]));
+        }
+
+        $this->files->copy($source, $destination);
+        $this->line(__('bagistoapi::app.graphql.install.config-published'));
+    }
+
+    /**
+     * Publish package assets via vendor:publish command.
+     */
+    protected function publishPackageAssets(): void
+    {
+        try {
+            $process = new Process([
+                'php',
+                'artisan',
+                'vendor:publish',
+                '--provider=Webkul\BagistoApi\Providers\BagistoApiServiceProvider',
+                '--no-interaction',
+            ]);
+
+            $process->run();
+
+            if (! $process->isSuccessful()) {
+                $this->warn(__('bagistoapi::app.graphql.install.publish-assets-warning', ['error' => $process->getErrorOutput()]));
+
+                return;
+            }
+
+            $this->line(__('bagistoapi::app.graphql.install.assets-published'));
+        } catch (\Exception $e) {
+            $this->warn(__('bagistoapi::app.graphql.install.publish-assets-warning', ['error' => $e->getMessage()]));
+        }
+    }
+
+    /**
+     * Update composer.json with required configurations.
+     */
+    protected function updateComposerAutoload(): void
+    {
+        $composerPath = base_path('composer.json');
+
+        if (! $this->files->exists($composerPath)) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.composer-file-not-found', ['file' => $composerPath]));
+        }
+
+        if (! is_writable($composerPath)) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.composer-permission-denied', ['file' => $composerPath]));
+        }
+
+        $composer = json_decode($this->files->get($composerPath), true);
+
+        if (! isset($composer['autoload']['psr-4'])) {
+            $composer['autoload']['psr-4'] = [];
+        }
+
+        $composer['autoload']['psr-4']['Webkul\\GraphQL\\'] = 'packages/Webkul/GraphQL/src';
+
+        if (! isset($composer['extra']['laravel']['dont-discover'])) {
+            $composer['extra']['laravel']['dont-discover'] = [];
+        }
+
+        if (! in_array('api-platform/laravel', $composer['extra']['laravel']['dont-discover'])) {
+            $composer['extra']['laravel']['dont-discover'][] = 'api-platform/laravel';
+        }
+
+        $this->files->put($composerPath, json_encode($composer, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT).PHP_EOL);
+        $this->line(__('bagistoapi::app.graphql.install.composer-updated'));
+    }
+
+    /**
+     * Make TranslatableModel abstract for API Platform compatibility.
+     */
+    protected function makeTranslatableModelAbstract(): void
+    {
+        $modelPath = base_path('packages/Webkul/Core/src/Eloquent/TranslatableModel.php');
+
+        if (! $this->files->exists($modelPath)) {
+            $this->comment(__('bagistoapi::app.graphql.install.translatable-not-found'));
+
+            return;
+        }
+
+        if (! is_writable($modelPath)) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.translatable-permission-denied', ['file' => $modelPath]));
+        }
+
+        $content = $this->files->get($modelPath);
+
+        if (preg_match('/abstract\s+class\s+TranslatableModel/', $content)) {
+            $this->comment(__('bagistoapi::app.graphql.install.translatable-already-abstract'));
+
+            return;
+        }
+
+        $content = preg_replace(
+            '/class\s+TranslatableModel/',
+            'abstract class TranslatableModel',
+            $content
+        );
+
+        $this->files->put($modelPath, $content);
+        $this->line(__('bagistoapi::app.graphql.install.translatable-made-abstract'));
+    }
+
+    /**
+     * Register API Platform providers in bootstrap/app.php.
+     */
+    protected function registerApiPlatformProviders(): void
+    {
+        $appPath = base_path('bootstrap/app.php');
+
+        if (! $this->files->exists($appPath)) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.providers-file-not-found', ['file' => $appPath]));
+        }
+
+        if (! is_writable($appPath)) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.providers-permission-denied', ['file' => $appPath]));
+        }
+
+        $content = $this->files->get($appPath);
+
+        if (strpos($content, 'ApiPlatformProvider::class') !== false) {
+            $this->comment(__('bagistoapi::app.graphql.install.providers-already-registered'));
+
+            return;
+        }
+
+        $providers = "\n->withProviders([\n"
+            ."     \\ApiPlatform\\Laravel\\ApiPlatformProvider::class,\n"
+            ."     \\ApiPlatform\\Laravel\\ApiPlatformDeferredProvider::class,\n"
+            ."     \\ApiPlatform\\Laravel\\Eloquent\\ApiPlatformEventProvider::class,\n"
+            ."])\n";
+
+        if (strpos($content, '->create()') !== false) {
+            $content = str_replace('->create()', $providers.'->create()', $content);
+        } else {
+            throw new \Exception(__('bagistoapi::app.graphql.install.providers-not-found'));
+        }
+
+        $this->files->put($appPath, $content);
+        $this->line(__('bagistoapi::app.graphql.install.providers-registered'));
+    }
+
+    /**
+     * Link or copy API Platform assets to public directory.
+     */
+    protected function linkApiPlatformAssets(): void
+    {
+        $vendorPath = base_path('vendor/api-platform/laravel/public');
+        $publicPath = public_path('vendor/api-platform');
+
+        if (! $this->files->exists($vendorPath)) {
+            $this->line(__('bagistoapi::app.graphql.install.vendor-path-not-found', ['path' => $vendorPath]));
+
+            return;
+        }
+
+        if ($this->files->exists($publicPath)) {
+            $this->line(__('bagistoapi::app.graphql.install.assets-already-linked', ['path' => $publicPath]));
+
+            return;
+        }
+
+        $publicVendorDir = dirname($publicPath);
+        if (! $this->files->exists($publicVendorDir)) {
+            $this->files->makeDirectory($publicVendorDir, 0755, true);
+        }
+
+        try {
+            if (!function_exists('symlink')) {
+                function symlink($target, $link) {
+                    // Windows系统使用不同的方法
+                    if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
+                        exec("mklink /J \"$link\" \"$target\"");
+                    } else {
+                        exec("ln -s \"$target\" \"$link\"");
+                    }
+                }
+            }
+            symlink($vendorPath, $publicPath);
+            $this->line(__('bagistoapi::app.graphql.install.asset-linked-success'));
+        } catch (\Exception $e) {
+            $this->comment(__('bagistoapi::app.graphql.install.symlink-create-failed'));
+            if (! $this->files->copyDirectory($vendorPath, $publicPath)) {
+                $this->warn(__('bagistoapi::app.graphql.install.asset-copy-warning'));
+
+                return;
+            }
+            $this->line(__('bagistoapi::app.graphql.install.asset-copied-success'));
+        }
+    }
+
+    /**
+     * Run database migrations.
+     */
+    protected function runDatabaseMigrations(): void
+    {
+        try {
+            $this->info(__('bagistoapi::app.graphql.install.running-migrations'));
+
+            $process = new Process([
+                'php',
+                'artisan',
+                'migrate',
+            ]);
+
+            $process->run();
+
+            if (! $process->isSuccessful()) {
+                throw new \Exception(__('bagistoapi::app.graphql.install.migrations-error').' '.$process->getErrorOutput());
+            }
+
+            $this->line(__('bagistoapi::app.graphql.install.migrations-completed'));
+        } catch (\Exception $e) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.migrations-error-running', ['error' => $e->getMessage()]));
+        }
+    }
+
+    /**
+     * Clear and optimize application caches.
+     */
+    protected function clearAndOptimizeCaches(): void
+    {
+        try {
+            $this->info(__('bagistoapi::app.graphql.install.clearing-caches'));
+
+            $clearProcess = new Process([
+                'php',
+                'artisan',
+                'config:clear',
+            ]);
+
+            $clearProcess->run();
+
+            if (! $clearProcess->isSuccessful()) {
+                throw new \Exception(__('bagistoapi::app.graphql.install.cache-clear-error').' '.$clearProcess->getErrorOutput());
+            }
+
+            $cacheProcess = new Process([
+                'php',
+                'artisan',
+                'cache:clear',
+            ]);
+
+            $cacheProcess->run();
+
+            if (! $cacheProcess->isSuccessful()) {
+                throw new \Exception(__('bagistoapi::app.graphql.install.cache-clear-error').' '.$cacheProcess->getErrorOutput());
+            }
+
+            $clearProcess = new Process([
+                'php',
+                'artisan',
+                'optimize:clear',
+            ]);
+
+            $clearProcess->run();
+
+            if (! $clearProcess->isSuccessful()) {
+                throw new \Exception(__('bagistoapi::app.graphql.install.cache-clear-error').' '.$clearProcess->getErrorOutput());
+            }
+
+            $optimizeProcess = new Process([
+                'php',
+                'artisan',
+                'optimize',
+            ]);
+
+            $optimizeProcess->run();
+
+            if (! $optimizeProcess->isSuccessful()) {
+                throw new \Exception(__('bagistoapi::app.graphql.install.cache-optimize-error').' '.$optimizeProcess->getErrorOutput());
+            }
+
+            $this->line(__('bagistoapi::app.graphql.install.caches-optimized'));
+        } catch (\Exception $e) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.cache-error', ['error' => $e->getMessage()]));
+        }
+    }
+
+    /**
+     * Generate a default storefront API key.
+     */
+    protected function generateApiKey(): void
+    {
+        try {
+            $this->info(__('bagistoapi::app.graphql.install.generating-api-key'));
+
+            $process = new Process([
+                'php',
+                'artisan',
+                'bagisto-api:generate-key',
+                '--name=Default Storefront Key1',
+            ]);
+
+            $process->run();
+
+            $output = $process->getOutput().$process->getErrorOutput();
+
+            if (stripos($output, 'already exists') !== false) {
+                $this->comment(__('bagistoapi::app.graphql.install.api-key-already-exists'));
+
+                return;
+            }
+
+            if (! $process->isSuccessful()) {
+                throw new \Exception(__('bagistoapi::app.graphql.install.api-key-generation-error').' '.$process->getErrorOutput());
+            }
+
+            $this->line(__('bagistoapi::app.graphql.install.api-key-generated'));
+
+            $generatedKey = $this->extractKeyFromOutput($output);
+            $this->saveStorefrontConfigToEnv($generatedKey);
+        } catch (\Exception $e) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.api-key-error', ['error' => $e->getMessage()]));
+        }
+    }
+
+    /**
+     * Extract API key from command output.
+     */
+    protected function extractKeyFromOutput(string $output): string
+    {
+        if (preg_match('/\b(pk_[a-zA-Z0-9_]+)\b/', $output, $matches)) {
+            return $matches[1];
+        }
+
+        return '';
+    }
+
+    /**
+     * Save storefront configuration to .env file.
+     */
+    protected function saveStorefrontConfigToEnv(string $generatedKey = ''): void
+    {
+        $envPath = base_path('.env');
+
+        if (! $this->files->exists($envPath)) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.env-file-not-found'));
+        }
+
+        if (! is_writable($envPath)) {
+            throw new \Exception(__('bagistoapi::app.graphql.install.env-permission-denied'));
+        }
+
+        $envContent = $this->files->get($envPath);
+
+        $envVariables = [
+            'STOREFRONT_DEFAULT_RATE_LIMIT' => '100',
+            'STOREFRONT_CACHE_TTL' => '60',
+            'STOREFRONT_KEY_PREFIX' => 'storefront_key_',
+            'STOREFRONT_PLAYGROUND_KEY' => $generatedKey,
+            'API_PLAYGROUND_AUTO_INJECT_STOREFRONT_KEY' => 'false',
+        ];
+
+        foreach ($envVariables as $key => $value) {
+            if (preg_match("/^{$key}=/m", $envContent)) {
+                $envContent = preg_replace("/^{$key}=.*/m", "{$key}={$value}", $envContent);
+            } else {
+                $envContent .= "\n{$key}={$value}";
+            }
+        }
+
+        $this->files->put($envPath, $envContent);
+
+        $this->line(__('bagistoapi::app.graphql.install.env-config-saved'));
+    }
+}

+ 5 - 0
packages/Webkul/BagistoApi/src/Contracts/GuestCartTokens.php

@@ -0,0 +1,5 @@
+<?php
+
+namespace Webkul\BagistoApi\Contracts;
+
+interface GuestCartTokens {}

+ 26 - 0
packages/Webkul/BagistoApi/src/Database/Migrations/2025_12_10_185743_create_cart_tokens_table.php

@@ -0,0 +1,26 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('guest_cart_tokens', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedInteger('cart_id')->unique()->index();
+            $table->string('token')->unique()->index();
+            $table->timestamp('created_at')->useCurrent();
+            $table->timestamp('updated_at')->useCurrentOnUpdate();
+
+            $table->foreign('cart_id')->references('id')->on('cart')->onDelete('cascade');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('guest_cart_tokens');
+    }
+};

+ 68 - 0
packages/Webkul/BagistoApi/src/Database/Migrations/2026_01_08_000000_create_storefront_keys_table.php

@@ -0,0 +1,68 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * Create storefront_keys table with complete API key management features:
+     * - Basic key storage and configuration
+     * - Key type (shop vs admin) for multi-tenant support
+     * - Expiration and rotation tracking
+     * - Usage monitoring for compliance
+     * - Soft deletes for audit trail
+     */
+    public function up(): void
+    {
+        if (Schema::hasTable('storefront_keys')) {
+            return;
+        }
+
+        Schema::create('storefront_keys', function (Blueprint $table) {
+            $table->id();
+
+            $table->string('name')->unique()->comment('Human-readable key name');
+            $table->enum('key_type', ['shop', 'admin'])
+                ->default('shop')
+                ->comment('API key type: shop (X-STOREFRONT-KEY) or admin (X-Admin-Key)');
+            $table->string('key')->unique()->index()
+                ->comment('The actual API key (prefixed with pk_storefront_)');
+            $table->boolean('is_active')->default(true)
+                ->comment('Whether this key is active and usable');
+
+            $table->integer('rate_limit')->default(100)
+                ->comment('Requests per minute allowed for this key');
+            $table->json('allowed_ips')->nullable()
+                ->comment('IP whitelist (JSON array) for additional security');
+
+            $table->timestamp('expires_at')->nullable()
+                ->comment('Key expiration date - after this, key is invalid');
+            $table->timestamp('last_used_at')->nullable()
+                ->comment('Last time this key was used - for usage tracking');
+            $table->timestamp('deprecation_date')->nullable()
+                ->comment('Date after which key is deprecated - used during rotation transition');
+            $table->foreignId('rotated_from_id')->nullable()
+                ->constrained('storefront_keys')
+                ->comment('Reference to the key this was rotated from - for audit trail');
+
+            $table->timestamps();
+            $table->softDeletes()->comment('Soft delete timestamp - for maintaining audit history');
+
+            $table->index('expires_at');
+            $table->index('last_used_at');
+            $table->index('deprecation_date');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('storefront_keys');
+    }
+};

+ 60 - 0
packages/Webkul/BagistoApi/src/Dto/AddToCartInput.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use Symfony\Component\Serializer\Annotation\Groups;
+
+class AddToCartInput
+{
+    #[Groups(['mutation'])]
+    public ?int $product_id = null;
+
+    #[Groups(['mutation'])]
+    public ?int $quantity = null;
+
+    #[Groups(['mutation'])]
+    public ?int $is_buy_now = null;
+
+    #[Groups(['mutation'])]
+    public ?array $options = null;
+
+    public function getProduct_id(): ?int
+    {
+        return $this->product_id;
+    }
+
+    public function setProduct_id(?int $product_id): void
+    {
+        $this->product_id = $product_id;
+    }
+
+    public function getQuantity(): ?int
+    {
+        return $this->quantity;
+    }
+
+    public function setQuantity(?int $quantity): void
+    {
+        $this->quantity = $quantity;
+    }
+
+    public function getIs_buy_now(): ?int
+    {
+        return $this->is_buy_now;
+    }
+
+    public function setIs_buy_now(?int $is_buy_now): void
+    {
+        $this->is_buy_now = $is_buy_now;
+    }
+
+    public function getOptions(): ?array
+    {
+        return $this->options;
+    }
+
+    public function setOptions(?array $options): void
+    {
+        $this->options = $options;
+    }
+}

+ 21 - 0
packages/Webkul/BagistoApi/src/Dto/ApplyCouponInput.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use Symfony\Component\Serializer\Annotation\Groups;
+
+class ApplyCouponInput
+{
+    #[Groups(['mutation'])]
+    public ?string $code = null;
+
+    public function getCode(): ?string
+    {
+        return $this->code;
+    }
+
+    public function setCode(?string $code): void
+    {
+        $this->code = $code;
+    }
+}

+ 26 - 0
packages/Webkul/BagistoApi/src/Dto/CancelOrderInput.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * DTO for canceling a customer order
+ *
+ * Defines input structure for the cancelOrder mutation
+ */
+#[ApiResource]
+class CancelOrderInput
+{
+    /**
+     * The ID of the order to cancel
+     */
+    #[ApiProperty(
+        description: 'The ID of the order to cancel',
+        required: true
+    )]
+    #[Groups(['mutation'])]
+    public ?int $orderId = null;
+}

+ 353 - 0
packages/Webkul/BagistoApi/src/Dto/CartData.php

@@ -0,0 +1,353 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * Shopping cart data transfer object
+ */
+class CartData
+{
+    #[Groups(['query', 'mutation'])]
+    public ?int $id = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Cart token for guest users')]
+    public ?string $cartToken = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?int $customerId = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?int $channelId = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?int $itemsCount = null;
+
+    /**
+     * Individual cart items - array of CartItemData DTO objects
+     *
+     * @var array<CartItemData>|null
+     */
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Individual cart items')]
+    public ?array $items = [];
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Subtotal before discounts and taxes')]
+    public ?float $subtotal = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $baseSubtotal = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Total discount amount')]
+    public ?float $discountAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $baseDiscountAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Total tax amount')]
+    public ?float $taxAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $baseTaxAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Shipping cost')]
+    public ?float $shippingAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $baseShippingAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Grand total')]
+    public ?float $grandTotal = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $baseGrandTotal = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted subtotal price')]
+    public ?string $formattedSubtotal = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted discount amount')]
+    public ?string $formattedDiscountAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted tax amount')]
+    public ?string $formattedTaxAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted shipping amount')]
+    public ?string $formattedShippingAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted grand total price')]
+    public ?string $formattedGrandTotal = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Applied coupon code')]
+    public ?string $couponCode = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?bool $success = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?string $message = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?array $carts = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Unique session token for guest cart')]
+    public ?string $sessionToken = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Is this a guest cart')]
+    public bool $isGuest = false;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Total quantity of all items')]
+    public ?int $itemsQty = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Subtotal including tax')]
+    public ?float $subTotalInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Base subtotal including tax')]
+    public ?float $baseSubTotalInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted subtotal including tax')]
+    public ?string $formattedSubTotalInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Tax total')]
+    public ?float $taxTotal = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted tax total')]
+    public ?string $formattedTaxTotal = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Shipping amount including tax')]
+    public ?float $shippingAmountInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Base shipping amount including tax')]
+    public ?float $baseShippingAmountInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Formatted shipping amount including tax')]
+    public ?string $formattedShippingAmountInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Billing address')]
+    public ?array $billingAddress = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Shipping address')]
+    public ?array $shippingAddress = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Applied taxes')]
+    public ?array $appliedTaxes = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Has stockable items')]
+    public ?bool $haveStockableItems = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Payment method code')]
+    public ?string $paymentMethod = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Payment method title')]
+    public ?string $paymentMethodTitle = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false, description: 'Selected shipping rate')]
+    public ?string $selectedShippingRate = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false, description: 'Selected shipping rate title')]
+    public ?string $selectedShippingRateTitle = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Payment redirect URL (if payment gateway redirect needed)')]
+    public ?string $paymentRedirectUrl = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Order ID after order creation')]
+    public ?string $orderId = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Redirect URL for buy now checkout')]
+    public ?string $redirectUri = null;
+
+    public function getSelectedShippingRate(): ?string
+    {
+        return $this->selectedShippingRate;
+    }
+
+    public function setSelectedShippingRate(?string $selectedShippingRate): void
+    {
+        $this->selectedShippingRate = $selectedShippingRate;
+    }
+
+    public function getSelectedShippingRateTitle(): ?string
+    {
+        return $this->selectedShippingRateTitle;
+    }
+
+    public function setSelectedShippingRateTitle(?string $selectedShippingRateTitle): void
+    {
+        $this->selectedShippingRateTitle = $selectedShippingRateTitle;
+    }
+
+    public static function fromModel(\Webkul\Checkout\Models\Cart $cart): self
+    {
+        $data = new self;
+
+        $data->id = $cart->id;
+        $data->cartToken = (string) $cart->id;
+        $data->customerId = $cart->customer_id;
+        $data->isGuest = ! $cart->customer_id;
+        $data->channelId = $cart->channel_id;
+        $data->itemsCount = $cart->items()->count();
+        $data->itemsQty = (int) ($cart->items_qty ?? 0);
+
+        $items = $cart->items()
+            ->with(['product'])
+            ->get()
+            ->map(fn ($item) => CartItemData::fromModel($item));
+
+        $data->items = $items->toArray();
+
+        $data->subtotal = (float) ($cart->sub_total ?? 0);
+        $data->baseSubtotal = (float) ($cart->base_sub_total ?? 0);
+        $data->formattedSubtotal = core()->formatPrice($cart->sub_total ?? 0);
+
+        $data->subTotalInclTax = (float) ($cart->sub_total_incl_tax ?? $cart->sub_total ?? 0);
+        $data->baseSubTotalInclTax = (float) ($cart->base_sub_total_incl_tax ?? $cart->base_sub_total ?? 0);
+        $data->formattedSubTotalInclTax = core()->formatPrice($cart->sub_total_incl_tax ?? $cart->sub_total ?? 0);
+
+        $data->taxAmount = (float) ($cart->tax_amount ?? 0);
+        $data->baseTaxAmount = (float) ($cart->base_tax_amount ?? 0);
+        $data->taxTotal = (float) ($cart->tax_total ?? $cart->tax_amount ?? 0);
+        $data->formattedTaxTotal = core()->formatPrice($cart->tax_total ?? $cart->tax_amount ?? 0);
+        $data->formattedTaxAmount = core()->formatPrice($cart->tax_amount ?? 0);
+
+        $data->discountAmount = (float) ($cart->discount_amount ?? 0);
+        $data->baseDiscountAmount = (float) ($cart->base_discount_amount ?? 0);
+        $data->formattedDiscountAmount = core()->formatPrice($cart->discount_amount ?? 0);
+
+        $data->shippingAmount = (float) ($cart->shipping_amount ?? 0);
+        $data->baseShippingAmount = (float) ($cart->base_shipping_amount ?? 0);
+        $data->formattedShippingAmount = core()->formatPrice($cart->shipping_amount ?? 0);
+
+        $data->shippingAmountInclTax = (float) ($cart->shipping_amount_incl_tax ?? $cart->shipping_amount ?? 0);
+        $data->baseShippingAmountInclTax = (float) ($cart->base_shipping_amount_incl_tax ?? $cart->base_shipping_amount ?? 0);
+        $data->formattedShippingAmountInclTax = core()->formatPrice($cart->shipping_amount_incl_tax ?? $cart->shipping_amount ?? 0);
+
+        $data->grandTotal = (float) ($cart->grand_total ?? 0);
+        $data->baseGrandTotal = (float) ($cart->base_grand_total ?? 0);
+        $data->formattedGrandTotal = core()->formatPrice($cart->grand_total ?? 0);
+
+        $additional = $cart->additional ?
+            (is_string($cart->additional) ? json_decode($cart->additional, true) : $cart->additional) : [];
+        $data->couponCode = $additional['coupon_code'] ?? null;
+
+        if ($cart->billing_address) {
+            $data->billingAddress = [
+                'id'        => $cart->billing_address->id,
+                'firstName' => $cart->billing_address->first_name,
+                'lastName'  => $cart->billing_address->last_name,
+                'email'     => $cart->billing_address->email,
+                'address'   => $cart->billing_address->address,
+                'city'      => $cart->billing_address->city,
+                'state'     => $cart->billing_address->state,
+                'country'   => $cart->billing_address->country,
+                'postcode'  => $cart->billing_address->postcode,
+                'phone'     => $cart->billing_address->phone,
+            ];
+        }
+
+        if ($cart->shipping_address) {
+            $data->shippingAddress = [
+                'id'        => $cart->shipping_address->id,
+                'firstName' => $cart->shipping_address->first_name,
+                'lastName'  => $cart->shipping_address->last_name,
+                'email'     => $cart->shipping_address->email,
+                'address'   => $cart->shipping_address->address,
+                'city'      => $cart->shipping_address->city,
+                'state'     => $cart->shipping_address->state,
+                'country'   => $cart->shipping_address->country,
+                'postcode'  => $cart->shipping_address->postcode,
+                'phone'     => $cart->shipping_address->phone,
+            ];
+        }
+
+        if ($cart->payment) {
+            $data->paymentMethod = $cart->payment->method;
+            $data->paymentMethodTitle = core()->getConfigData('sales.payment_methods.'.$cart->payment->method.'.title');
+        }
+
+        try {
+            $taxes = collect(\Webkul\Tax\Facades\Tax::getTaxRatesWithAmount($cart, true))->map(function ($rate) {
+                return core()->currency($rate ?? 0);
+            })->toArray();
+            $data->appliedTaxes = $taxes;
+        } catch (\Exception $e) {
+            $data->appliedTaxes = [];
+        }
+
+        $data->haveStockableItems = $cart->items()->whereHas('product', function ($q) {
+            $q->where('type', 'simple');
+        })->count() > 0;
+
+        if ($cart->selected_shipping_rate) {
+            $data->selectedShippingRate = $cart->selected_shipping_rate->method ?? null;
+            $data->selectedShippingRateTitle = $cart->selected_shipping_rate->method_title ?? null;
+        } else {
+            $data->selectedShippingRate = null;
+            $data->selectedShippingRateTitle = null;
+        }
+
+        return $data;
+    }
+
+    public static function collection(iterable $carts): array
+    {
+        $cartDataCollection = [];
+        foreach ($carts as $cart) {
+            $cartDataCollection[] = self::fromModel($cart);
+        }
+
+        return $cartDataCollection;
+    }
+
+    public function getItems(): ?array
+    {
+        return $this->items;
+    }
+
+    public function setItems(?array $items): void
+    {
+        $this->items = $items;
+    }
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Redirect URL for payment gateway')]
+    public ?string $redirectUrl = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Order ID after successful order creation')]
+    public ?string $orderIncrementId = null;
+}

+ 287 - 0
packages/Webkul/BagistoApi/src/Dto/CartInput.php

@@ -0,0 +1,287 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * Input DTO for cart operations with token-based authentication
+ *
+ * Supports both authenticated users (via bearer token) and guest users (via cart token).
+ * Operations: add product, update item quantity, remove item, get cart, get all carts
+ *
+ * Authentication token is passed via Authorization: Bearer header, not as input parameter.
+ */
+class CartInput
+{
+    /**
+     * ID field (optional, for GraphQL API Platform compatibility)
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation', 'query'])]
+    public ?string $id = null;
+
+    /**
+     * Cart ID (optional, for specific cart operations)
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation', 'query'])]
+    public ?int $cartId = null;
+
+    /**
+     * Product ID (required for addProduct operation)
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?int $productId = null;
+
+    /**
+     * Cart item ID (required for update/remove operations)
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?int $cartItemId = null;
+
+    /**
+     * Quantity of items to add/update
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?int $quantity = null;
+
+    /**
+     * Product options/attributes (JSON)
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?array $options = null;
+
+    /**
+     * Coupon code for discount
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?string $couponCode = null;
+
+    /**
+     * Shipping address ID
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?int $shippingAddressId = null;
+
+    /**
+     * Billing address ID
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?int $billingAddressId = null;
+
+    /**
+     * Shipping method code
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?string $shippingMethod = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Selected shipping rate object')]
+    public $selectedShippingRate = null;
+
+    /**
+     * Payment method code
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?string $paymentMethod = null;
+
+    /**
+     * Session ID for creating new guest cart (createOrGetCart operation)
+     * Used to identify guest session and generate unique token
+     */
+    #[ApiProperty(required: false, description: 'Session ID for cart creation')]
+    #[Groups(['mutation', 'query'])]
+    public ?string $sessionId = null;
+
+    /**
+     * Flag to create new cart instead of using existing one
+     * Used in createOrGetCart mutation
+     */
+    #[ApiProperty(required: false, description: 'Generate new cart with unique token')]
+    #[Groups(['mutation'])]
+    public ?bool $createNew = false;
+
+    /**
+     * Array of cart item IDs for bulk operations (remove multiple, move to wishlist)
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?array $itemIds = null;
+
+    /**
+     * Array of quantities for bulk operations
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?array $quantities = null;
+
+    /**
+     * Country code for shipping estimation
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?string $country = null;
+
+    /**
+     * State/Province code for shipping estimation
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?string $state = null;
+
+    /**
+     * Postal code for shipping estimation
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?string $postcode = null;
+
+    /**
+     * Device token for push notifications (optional)
+     */
+    #[ApiProperty(required: false, description: 'Device token for push notifications')]
+    #[Groups(['mutation'])]
+    public ?string $deviceToken = null;
+
+    /**
+     * Is buy now flag (0 = add to cart, 1 = buy now)
+     * Used for direct checkout
+     */
+    #[ApiProperty(required: false, description: 'Is buy now (0 = cart, 1 = buy now)')]
+    #[Groups(['mutation'])]
+    public ?int $isBuyNow = 0;
+
+    /**
+     * Bundle product options
+     * Format: [option_id => [product_id1, product_id2, ...]]
+     * Example: [1 => [2, 3], 2 => [5]]
+     * Used for Bundle products
+     */
+    #[ApiProperty(required: false, description: 'Bundle options JSON string. Example: {"1":[1],"2":[2]}')]
+    #[Groups(['mutation'])]
+    public ?string $bundleOptions = null;
+
+    /**
+     * Bundle product option quantities
+     * Format: [option_id => quantity]
+     * Example: [1 => 2, 2 => 1]
+     * Used for Bundle products
+     */
+    #[ApiProperty(required: false, description: 'Bundle option quantities JSON string. Example: {"1":1,"2":2}')]
+    #[Groups(['mutation'])]
+    public ?string $bundleOptionQty = null;
+
+    /**
+     * Selected configurable product option (child product ID)
+     * Used for Configurable products
+     */
+    #[ApiProperty(required: false, description: 'Selected configurable option (child product ID)')]
+    #[Groups(['mutation'])]
+    public ?int $selectedConfigurableOption = null;
+
+    /**
+     * Super attribute values for configurable products
+     * Format: [attribute_id => option_value]
+     * Example: [123 => 56, 124 => 57]
+     * Used for Configurable products
+     */
+    #[ApiProperty(required: false, description: 'Super attributes for configurable products')]
+    #[Groups(['mutation'])]
+    public ?array $superAttribute = null;
+
+    /**
+     * Quantities for grouped product associated products
+     * Format: [associated_product_id => quantity]
+     * Example: [101 => 2, 102 => 1]
+     * Used for Grouped products
+     */
+    #[ApiProperty(required: false, description: 'Quantities for grouped product associated products')]
+    #[Groups(['mutation'])]
+    public ?array $qty = null;
+
+    /**
+     * Quantities for grouped product associated products (GraphQL-friendly).
+     *
+     * GraphQL input objects cannot have numeric keys (e.g. {101: 2}), so this accepts a JSON string
+     * that can represent a map: {"101":2,"102":1}.
+     *
+     * Used for Grouped products.
+     */
+    #[ApiProperty(required: false, description: 'Grouped product quantities as JSON string. Example: {"101":2,"102":1}')]
+    #[Groups(['mutation'])]
+    public ?string $groupedQty = null;
+
+    /**
+     * Downloadable product links to purchase
+     * Format: [link_id1, link_id2, ...]
+     * Used for Downloadable products
+     */
+    #[ApiProperty(
+        required: false,
+        description: 'Downloadable product link IDs'
+    )]
+    #[Groups(['mutation'])]
+    public ?array $links = null;
+
+    /**
+     * Customizable options for products
+     * Format: [option_id => value]
+     * Used for Virtual products with customizable options
+     */
+    #[ApiProperty(required: false, description: 'Customizable options')]
+    #[Groups(['mutation'])]
+    public ?array $customizableOptions = null;
+
+    /**
+     * Additional data for products
+     * Used for any extra product-specific data
+     */
+    #[ApiProperty(required: false, description: 'Additional product data')]
+    #[Groups(['mutation'])]
+    public ?array $additional = null;
+
+    /**
+     * Booking product options (GraphQL-friendly).
+     *
+     * Bagisto expects booking options under the `booking` key when adding a booking product to cart.
+     * GraphQL input objects cannot represent arbitrary/nested maps reliably in all clients, so this
+     * accepts a JSON string (decoded server-side) that will be forwarded as `booking`.
+     *
+     * Examples:
+     * - Appointment/Default/Table: {"type":"appointment","date":"2026-03-12","slot":"10:00 AM - 11:00 AM"}
+     * - Rental (hourly): {"type":"rental","date":"2026-03-12","slot":{"from":1710208800,"to":1710212400}}
+     * - Rental (daily): {"type":"rental","date_from":"2026-03-12","date_to":"2026-03-14","renting_type":"daily"}
+     * - Event: {"type":"event","qty":{"12":1,"13":2}} (at least one ticket qty > 0 required)
+     */
+    #[ApiProperty(required: false, description: 'Booking options as JSON string')]
+    #[Groups(['mutation'])]
+    public ?string $booking = null;
+
+    /**
+     * Optional booking note (mainly for table booking).
+     *
+     * If provided, it will be merged into the decoded `booking` payload as `booking.note`.
+     * This avoids clients having to embed/escape the note inside the booking JSON string.
+     */
+    #[ApiProperty(required: false, description: 'Booking note / special request')]
+    #[Groups(['mutation'])]
+    public ?string $specialNote = null;
+
+    /**
+     * Backward compatibility: previous name for `specialNote`.
+     */
+    #[ApiProperty(required: false, description: 'Deprecated: use specialNote')]
+    #[Groups(['mutation'])]
+    public ?string $bookingNote = null;
+}

+ 167 - 0
packages/Webkul/BagistoApi/src/Dto/CartItemData.php

@@ -0,0 +1,167 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiResource;
+use ApiPlatform\Metadata\GraphQl\Query;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * Data Transfer Object for Cart Items
+ *
+ * Represents individual items in a shopping cart with pricing and product information.
+ * Used in API responses for cart operations.
+ *
+ * This class is registered as an ApiResource to enable GraphQL type generation,
+ * but the Query operation with output: false means it won't be exposed as a standalone query.
+ */
+#[ApiResource(
+    shortName: 'CartItem',
+    graphQlOperations: [
+        new Query(name: 'item_query', output: false),
+    ]
+)]
+class CartItemData
+{
+    #[Groups(['query', 'mutation'])]
+    public ?int $id = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?int $cartId = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?int $productId = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?string $name = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?string $sku = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?int $quantity = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $price = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $basePrice = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $total = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $baseTotal = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $discountAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $baseDiscountAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $taxAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $baseTaxAmount = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?array $options = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?string $type = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?string $formattedPrice = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?string $formattedTotal = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $priceInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $basePriceInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?string $formattedPriceInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $totalInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?float $baseTotalInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?string $formattedTotalInclTax = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?string $baseImage = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?string $productUrlKey = null;
+
+    #[Groups(['query', 'mutation'])]
+    public ?bool $canChangeQty = null;
+
+    /**
+     * Create CartItemData from CartItem model
+     */
+    public static function fromModel(\Webkul\Checkout\Models\CartItem $item): self
+    {
+        $data = new self;
+
+        $data->id = $item->id;
+        $data->cartId = $item->cart_id;
+        $data->productId = $item->product_id;
+        $data->name = $item->name ?? ($item->product?->name ?? '');
+        $data->sku = $item->sku ?? ($item->product?->sku ?? '');
+        $data->quantity = (int) $item->quantity;
+        $data->type = $item->type;
+
+        // Base prices
+        $data->price = (float) ($item->price ?? 0);
+        $data->basePrice = (float) ($item->base_price ?? 0);
+        $data->formattedPrice = core()->formatPrice($item->price ?? 0);
+
+        // Prices including tax
+        $data->priceInclTax = (float) ($item->price_incl_tax ?? $item->price ?? 0);
+        $data->basePriceInclTax = (float) ($item->base_price_incl_tax ?? $item->base_price ?? 0);
+        $data->formattedPriceInclTax = core()->formatPrice($item->price_incl_tax ?? $item->price ?? 0);
+
+        // Line totals
+        $data->total = (float) ($item->total ?? 0);
+        $data->baseTotal = (float) ($item->base_total ?? 0);
+        $data->formattedTotal = core()->formatPrice($item->total ?? 0);
+
+        // Line totals including tax
+        $data->totalInclTax = (float) ($item->total_incl_tax ?? $item->total ?? 0);
+        $data->baseTotalInclTax = (float) ($item->base_total_incl_tax ?? $item->base_total ?? 0);
+        $data->formattedTotalInclTax = core()->formatPrice($item->total_incl_tax ?? $item->total ?? 0);
+
+        // Discounts
+        $data->discountAmount = (float) ($item->discount_amount ?? 0);
+        $data->baseDiscountAmount = (float) ($item->base_discount_amount ?? 0);
+
+        // Tax
+        $data->taxAmount = (float) ($item->tax_amount ?? 0);
+        $data->baseTaxAmount = (float) ($item->base_tax_amount ?? 0);
+
+        // Product info
+        $data->options = $item->additional ?
+            (is_string($item->additional) ? json_decode($item->additional, true) : $item->additional) : null;
+
+        // Base image
+        if ($item->product) {
+            try {
+                $data->baseImage = json_encode($item->product->getTypeInstance()->getBaseImage($item));
+            } catch (\Exception $e) {
+                $data->baseImage = null;
+            }
+            $data->productUrlKey = $item->product->url_key;
+            $data->canChangeQty = $item->product->getTypeInstance()->showQuantityBox();
+        }
+
+        return $data;
+    }
+}

+ 124 - 0
packages/Webkul/BagistoApi/src/Dto/CheckoutAddressInput.php

@@ -0,0 +1,124 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * CheckoutAddressInput - GraphQL Input DTO for Checkout Address
+ *
+ * Input for storing billing and shipping addresses during checkout
+ * Authentication token is passed via Authorization: Bearer header, NOT as input parameter
+ */
+class CheckoutAddressInput
+{
+    // Billing Address
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Billing first name')]
+    public ?string $billingFirstName = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Billing last name')]
+    public ?string $billingLastName = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Billing email')]
+    public ?string $billingEmail = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Billing company name')]
+    public ?string $billingCompanyName = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Billing address')]
+    public ?string $billingAddress = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Billing country')]
+    public ?string $billingCountry = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Billing state')]
+    public ?string $billingState = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Billing city')]
+    public ?string $billingCity = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Billing postcode')]
+    public ?string $billingPostcode = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Billing phone number')]
+    public ?string $billingPhoneNumber = null;
+
+    // Shipping Address
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Shipping first name')]
+    public ?string $shippingFirstName = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Shipping last name')]
+    public ?string $shippingLastName = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Shipping email')]
+    public ?string $shippingEmail = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Shipping company name')]
+    public ?string $shippingCompanyName = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Shipping address')]
+    public ?string $shippingAddress = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Shipping country')]
+    public ?string $shippingCountry = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Shipping state')]
+    public ?string $shippingState = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Shipping city')]
+    public ?string $shippingCity = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Shipping postcode')]
+    public ?string $shippingPostcode = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Shipping phone number')]
+    public ?string $shippingPhoneNumber = null;
+
+    // Flags
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Use address for shipping')]
+    public ?bool $useForShipping = null;
+
+    // Additional fields for shipping and payment methods
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Shipping method code')]
+    public ?string $shippingMethod = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Payment method code')]
+    public ?string $paymentMethod = null;
+
+    // Payment callback URLs (for headless frontends)
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Payment success callback URL')]
+    public ?string $paymentSuccessUrl = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Payment failure callback URL')]
+    public ?string $paymentFailureUrl = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Payment cancel callback URL')]
+    public ?string $paymentCancelUrl = null;
+}

+ 116 - 0
packages/Webkul/BagistoApi/src/Dto/CheckoutAddressOutput.php

@@ -0,0 +1,116 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * CheckoutAddressOutput - GraphQL Output DTO for Checkout Address
+ *
+ * Output for retrieving billing and shipping addresses during checkout
+ */
+class CheckoutAddressOutput
+{
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?int $id = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $cartToken = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?int $customerId = null;
+
+    // Billing Address
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $billingFirstName = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $billingLastName = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $billingEmail = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $billingCompanyName = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $billingAddress = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $billingCountry = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $billingState = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $billingCity = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $billingPostcode = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $billingPhoneNumber = null;
+
+    // Shipping Address
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $shippingFirstName = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $shippingLastName = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $shippingEmail = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $shippingCompanyName = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $shippingAddress = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $shippingCountry = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $shippingState = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $shippingCity = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $shippingPostcode = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $shippingPhoneNumber = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?bool $success = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $message = null;
+}

+ 31 - 0
packages/Webkul/BagistoApi/src/Dto/CheckoutAddressPayload.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * CheckoutAddressPayload - Response DTO for CreateCheckoutAddress mutation
+ *
+ * Wraps the created address and cart information in a payload structure
+ * that matches the expected GraphQL response format
+ */
+class CheckoutAddressPayload
+{
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'The created or updated cart address')]
+    public ?CheckoutAddressOutput $checkoutAddress = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Current cart state')]
+    public ?CartData $cart = null;
+
+    public function __construct(
+        ?CheckoutAddressOutput $checkoutAddress = null,
+        ?CartData $cart = null
+    ) {
+        $this->checkoutAddress = $checkoutAddress;
+        $this->cart = $cart;
+    }
+}

+ 11 - 0
packages/Webkul/BagistoApi/src/Dto/CheckoutAddressQueryInput.php

@@ -0,0 +1,11 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+/**
+ * CheckoutAddressQueryInput - GraphQL Input DTO for Checkout Address Query
+ *
+ * Input for querying billing and shipping addresses during checkout
+ * Authentication token is passed via Authorization: Bearer header, NOT as input parameter
+ */
+class CheckoutAddressQueryInput {}

+ 28 - 0
packages/Webkul/BagistoApi/src/Dto/ContactUsInput.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * DTO for Contact Us form submission via GraphQL mutation and REST API
+ */
+class ContactUsInput
+{
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation', 'query'])]
+    public ?string $id = null;
+
+    #[Groups(['mutation'])]
+    public string $name;
+
+    #[Groups(['mutation'])]
+    public string $email;
+
+    #[Groups(['mutation'])]
+    public ?string $contact = null;
+
+    #[Groups(['mutation'])]
+    public string $message;
+}

+ 20 - 0
packages/Webkul/BagistoApi/src/Dto/ContactUsOutput.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * DTO for Contact Us form submission response
+ */
+class ContactUsOutput
+{
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public bool $success;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public string $message;
+}

+ 20 - 0
packages/Webkul/BagistoApi/src/Dto/CreateCompareItemInput.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+
+/**
+ * DTO for creating a compare item
+ *
+ * Defines the input structure for the createCompareItem mutation
+ * Customer ID is automatically determined from the authenticated user
+ */
+class CreateCompareItemInput
+{
+    /**
+     * Product ID to add to comparison
+     */
+    #[ApiProperty(description: 'The ID of the product to add to comparison')]
+    public ?int $productId = null;
+}

+ 58 - 0
packages/Webkul/BagistoApi/src/Dto/CreateProductReviewInput.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiResource;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * DTO for creating product reviews via GraphQL mutation
+ * This explicitly defines all input fields for the GraphQL schema
+ */
+#[ApiResource]
+class CreateProductReviewInput
+{
+    #[Groups(['mutation'])]
+    public int $productId;
+
+    #[Groups(['mutation'])]
+    public string $title;
+
+    #[Groups(['mutation'])]
+    public string $comment;
+
+    #[Groups(['mutation'])]
+    public int $rating;
+
+    #[Groups(['mutation'])]
+    public string $name;
+
+    #[Groups(['mutation'])]
+    public ?string $email = null;
+
+    #[Groups(['mutation'])]
+    public ?int $status = null;
+
+    #[Groups(['mutation'])]
+    public ?string $attachments;
+
+    public function __construct(
+        int $productId,
+        string $title,
+        string $comment,
+        int $rating,
+        string $name,
+        ?string $email = null,
+        ?int $status = null,
+        ?string $attachments = '',
+    ) {
+        $this->productId = $productId;
+        $this->title = $title;
+        $this->comment = $comment;
+        $this->rating = $rating;
+        $this->name = $name;
+        $this->email = $email;
+        $this->status = $status;
+        $this->attachments = $attachments;
+    }
+}

+ 20 - 0
packages/Webkul/BagistoApi/src/Dto/CreateWishlistInput.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+
+/**
+ * DTO for creating a wishlist item
+ *
+ * Defines the input structure for the createWishlist mutation
+ * Customer ID and channel ID are automatically determined from the authenticated user and current channel
+ */
+class CreateWishlistInput
+{
+    /**
+     * Product ID to add to wishlist
+     */
+    #[ApiProperty(description: 'The ID of the product to add to wishlist')]
+    public ?int $productId = null;
+}

+ 117 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerAddressInput.php

@@ -0,0 +1,117 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
+
+/**
+ * Input DTO for customer address operations with token-based authentication
+ * Token is passed via Authorization: Bearer header, NOT as input parameter
+ *
+ * NOTE: Token is NOT a DTO property. It is extracted from the Authorization header
+ * via TokenHeaderFacade::getAuthorizationBearerToken() in the processor.
+ */
+class CustomerAddressInput
+{
+    /**
+     * Identifier for API Platform GraphQL serialization
+     */
+    #[SerializedName('id')]
+    #[ApiProperty(identifier: true)]
+    #[Groups(['mutation'])]
+    public ?int $id = null;
+
+    /**
+     * Address ID (required for update/delete, not used for create)
+     */
+    #[SerializedName('addressId')]
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?int $addressId = null;
+
+    /**
+     * First name
+     */
+    #[SerializedName('firstName')]
+    #[Groups(['mutation'])]
+    public ?string $firstName = null;
+
+    /**
+     * Last name
+     */
+    #[SerializedName('lastName')]
+    #[Groups(['mutation'])]
+    public ?string $lastName = null;
+
+    /**
+     * Email address
+     */
+    #[SerializedName('email')]
+    #[Groups(['mutation'])]
+    public ?string $email = null;
+
+    /**
+     * Phone number
+     */
+    #[SerializedName('phone')]
+    #[Groups(['mutation'])]
+    public ?string $phone = null;
+
+    /**
+     * Street address line 1
+     */
+    #[SerializedName('address1')]
+    #[Groups(['mutation'])]
+    public ?string $address1 = null;
+
+    /**
+     * Street address line 2
+     */
+    #[SerializedName('address2')]
+    #[Groups(['mutation'])]
+    public ?string $address2 = null;
+
+    /**
+     * Country
+     */
+    #[SerializedName('country')]
+    #[Groups(['mutation'])]
+    public ?string $country = null;
+
+    /**
+     * State/Province
+     */
+    #[SerializedName('state')]
+    #[Groups(['mutation'])]
+    public ?string $state = null;
+
+    /**
+     * City
+     */
+    #[SerializedName('city')]
+    #[Groups(['mutation'])]
+    public ?string $city = null;
+
+    /**
+     * Postal code
+     */
+    #[SerializedName('postcode')]
+    #[Groups(['mutation'])]
+    public ?string $postcode = null;
+
+    /**
+     * Use for shipping
+     */
+    #[SerializedName('useForShipping')]
+    #[Groups(['mutation'])]
+    public ?bool $useForShipping = null;
+
+    /**
+     * Set as default address
+     */
+    #[SerializedName('defaultAddress')]
+    #[Groups(['mutation'])]
+    public ?bool $defaultAddress = null;
+}

+ 56 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerAddressOutput.php

@@ -0,0 +1,56 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use Symfony\Component\Serializer\Annotation\Groups;
+
+class CustomerAddressOutput
+{
+    #[Groups(['read'])]
+    public ?int $id = null;
+
+    #[Groups(['read'])]
+    public ?string $firstName = null;
+
+    #[Groups(['read'])]
+    public ?string $lastName = null;
+
+    #[Groups(['read'])]
+    public ?string $email = null;
+
+    #[Groups(['read'])]
+    public ?string $phone = null;
+
+    #[Groups(['read'])]
+    public ?string $company = null;
+
+    #[Groups(['read'])]
+    public ?string $address1 = null;
+
+    #[Groups(['read'])]
+    public ?string $address2 = null;
+
+    #[Groups(['read'])]
+    public ?string $country = null;
+
+    #[Groups(['read'])]
+    public ?string $state = null;
+
+    #[Groups(['read'])]
+    public ?string $city = null;
+
+    #[Groups(['read'])]
+    public ?string $postcode = null;
+
+    #[Groups(['read'])]
+    public ?bool $useForShipping = null;
+
+    #[Groups(['read'])]
+    public ?bool $defaultAddress = null;
+
+    #[Groups(['read'])]
+    public ?bool $success = null;
+
+    #[Groups(['read'])]
+    public ?string $message = null;
+}

+ 14 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerLoginInput.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+/**
+ * DTO for customer login input
+ */
+class CustomerLoginInput
+{
+    public function __construct(
+        public ?string $email = null,
+        public ?string $password = null,
+    ) {}
+}

+ 120 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerProfileInput.php

@@ -0,0 +1,120 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * Input DTO for customer profile operations with token-based authentication
+ * Token is passed via Authorization: Bearer header, NOT as input parameter
+ *
+ * NOTE: Token is NOT a DTO property. It is extracted from the Authorization header
+ * via TokenHeaderFacade::getAuthorizationBearerToken() in the processor.
+ */
+class CustomerProfileInput
+{
+    /**
+     * Customer ID (optional for get, required for update/delete when multiple profiles exist)
+     */
+    #[ApiProperty(required: false)]
+    #[Groups(['mutation'])]
+    public ?string $id = null;
+
+    /**
+     * First name
+     */
+    #[Groups(['mutation'])]
+    public ?string $firstName = null;
+
+    /**
+     * Last name
+     */
+    #[Groups(['mutation'])]
+    public ?string $lastName = null;
+
+    /**
+     * Email address
+     */
+    #[Groups(['mutation'])]
+    public ?string $email = null;
+
+    /**
+     * Phone number
+     */
+    #[Groups(['mutation'])]
+    public ?string $phone = null;
+
+    /**
+     * Gender
+     */
+    #[Groups(['mutation'])]
+    public ?string $gender = null;
+
+    /**
+     * Date of birth
+     */
+    #[Groups(['mutation'])]
+    public ?string $dateOfBirth = null;
+
+    /**
+     * Current password (for password change verification)
+     */
+    #[Groups(['mutation'])]
+    public ?string $password = null;
+
+    /**
+     * New password confirmation
+     */
+    #[Groups(['mutation'])]
+    public ?string $confirmPassword = null;
+
+    /**
+     * Customer status
+     */
+    #[Groups(['mutation'])]
+    public ?string $status = null;
+
+    /**
+     * Newsletter subscription
+     */
+    #[Groups(['mutation'])]
+    public ?bool $subscribedToNewsLetter = null;
+
+    /**
+     * Verification status
+     */
+    #[Groups(['mutation'])]
+    public ?string $isVerified = null;
+
+    /**
+     * Suspension status
+     */
+    #[Groups(['mutation'])]
+    public ?string $isSuspended = null;
+
+    /**
+     * Customer profile image (base64 encoded)
+     * Format: data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEA...
+     */
+    #[Groups(['mutation'])]
+    public ?string $image = null;
+
+    /**
+     * Flag to delete existing image
+     */
+    #[Groups(['mutation'])]
+    public ?bool $deleteImage = null;
+
+    /**
+     * Success status of the operation
+     */
+    #[Groups(['mutation'])]
+    public ?bool $success = null;
+
+    /**
+     * Response message
+     */
+    #[Groups(['mutation'])]
+    public ?string $message = null;
+}

+ 116 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerProfileOutput.php

@@ -0,0 +1,116 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
+
+/**
+ * Output DTO for customer profile mutation responses
+ * Uses camelCase for GraphQL field names
+ */
+class CustomerProfileOutput
+{
+    #[ApiProperty(readable: true, writable: false)]
+    #[SerializedName('id')]
+    #[Groups(['mutation'])]
+    public ?string $id = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[SerializedName('_id')]
+    #[Groups(['mutation'])]
+    public ?string $_id = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[SerializedName('firstName')]
+    #[Groups(['mutation'])]
+    public ?string $firstName = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[SerializedName('lastName')]
+    #[Groups(['mutation'])]
+    public ?string $lastName = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $email = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $phone = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $gender = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[SerializedName('dateOfBirth')]
+    #[Groups(['mutation'])]
+    public ?string $dateOfBirth = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $status = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[SerializedName('subscribedToNewsLetter')]
+    #[Groups(['mutation'])]
+    public ?bool $subscribedToNewsLetter = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[SerializedName('isVerified')]
+    #[Groups(['mutation'])]
+    public ?string $isVerified = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[SerializedName('isSuspended')]
+    #[Groups(['mutation'])]
+    public ?string $isSuspended = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $image = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?bool $success = null;
+
+    #[ApiProperty(readable: true, writable: false)]
+    #[Groups(['mutation'])]
+    public ?string $message = null;
+
+    public function __construct(
+        ?string $id = null,
+        ?string $_id = null,
+        ?string $firstName = null,
+        ?string $lastName = null,
+        ?string $email = null,
+        ?string $phone = null,
+        ?string $gender = null,
+        ?string $dateOfBirth = null,
+        ?string $status = null,
+        ?bool $subscribedToNewsLetter = null,
+        ?string $isVerified = null,
+        ?string $isSuspended = null,
+        ?string $image = null,
+        ?bool $success = null,
+        ?string $message = null,
+    ) {
+        $this->id = $id;
+        $this->_id = $_id;
+        $this->firstName = $firstName;
+        $this->lastName = $lastName;
+        $this->email = $email;
+        $this->phone = $phone;
+        $this->gender = $gender;
+        $this->dateOfBirth = $dateOfBirth;
+        $this->status = $status;
+        $this->subscribedToNewsLetter = $subscribedToNewsLetter;
+        $this->isVerified = $isVerified;
+        $this->isSuspended = $isSuspended;
+        $this->image = $image;
+        $this->success = $success;
+        $this->message = $message;
+    }
+}

+ 52 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerVerifyOutput.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\SerializedName;
+
+/**
+ * DTO for customer info returned from token verification
+ */
+class CustomerVerifyOutput
+{
+    #[ApiProperty(writable: false, readable: true)]
+    #[SerializedName('id')]
+    public ?int $id = null;
+
+    #[ApiProperty(writable: false, readable: true)]
+    #[SerializedName('firstName')]
+    public ?string $firstName = null;
+
+    #[ApiProperty(writable: false, readable: true)]
+    #[SerializedName('lastName')]
+    public ?string $lastName = null;
+
+    #[ApiProperty(writable: false, readable: true)]
+    #[SerializedName('email')]
+    public ?string $email = null;
+
+    #[ApiProperty(writable: false, readable: true)]
+    #[SerializedName('isValid')]
+    public ?bool $isValid = null;
+
+    #[ApiProperty(writable: false, readable: true)]
+    #[SerializedName('message')]
+    public ?string $message = null;
+
+    public function __construct(
+        ?int $id = null,
+        ?string $firstName = null,
+        ?string $lastName = null,
+        ?string $email = null,
+        ?bool $isValid = null,
+        ?string $message = null,
+    ) {
+        $this->id = $id;
+        $this->firstName = $firstName;
+        $this->lastName = $lastName;
+        $this->email = $email;
+        $this->isValid = $isValid;
+        $this->message = $message;
+    }
+}

+ 12 - 0
packages/Webkul/BagistoApi/src/Dto/DeleteAllCompareItemsInput.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+/**
+ * DTO for deleting all compare items
+ *
+ * Empty input — customer is determined from the auth token
+ */
+class DeleteAllCompareItemsInput
+{
+}

+ 12 - 0
packages/Webkul/BagistoApi/src/Dto/DeleteAllWishlistsInput.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+/**
+ * DTO for deleting all wishlist items
+ *
+ * Empty input — customer is determined from the auth token
+ */
+class DeleteAllWishlistsInput
+{
+}

+ 19 - 0
packages/Webkul/BagistoApi/src/Dto/DeleteCompareItemInput.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+
+/**
+ * DTO for deleting compare items
+ *
+ * Defines the input structure for deleting items from compare list
+ */
+class DeleteCompareItemInput
+{
+    /**
+     * Compare item ID to delete
+     */
+    #[ApiProperty(description: 'The ID of the compare item to delete')]
+    public ?string $id = null;
+}

+ 19 - 0
packages/Webkul/BagistoApi/src/Dto/DeleteWishlistInput.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+
+/**
+ * DTO for deleting wishlist items
+ *
+ * Defines the input structure for deleting items from wishlist
+ */
+class DeleteWishlistInput
+{
+    /**
+     * Wishlist item ID to delete
+     */
+    #[ApiProperty(description: 'The ID of the wishlist item to delete')]
+    public ?string $id = null;
+}

+ 21 - 0
packages/Webkul/BagistoApi/src/Dto/DestroySelectedInput.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use Symfony\Component\Serializer\Annotation\Groups;
+
+class DestroySelectedInput
+{
+    #[Groups(['mutation'])]
+    public ?array $ids = null;
+
+    public function getIds(): ?array
+    {
+        return $this->ids;
+    }
+
+    public function setIds(?array $ids): void
+    {
+        $this->ids = $ids;
+    }
+}

+ 29 - 0
packages/Webkul/BagistoApi/src/Dto/DownloadLinkOutput.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+
+/**
+ * DownloadLinkOutput
+ *
+ * Output DTO for download link generation mutation
+ */
+#[ApiResource(operations: [])]
+class DownloadLinkOutput
+{
+    public function __construct(
+        #[ApiProperty(identifier: true, writable: false)]
+        public string $id = '1',
+
+        #[ApiProperty(description: 'Temporary download token')]
+        public ?string $token = null,
+
+        #[ApiProperty(description: 'Download URL')]
+        public ?string $url = null,
+
+        #[ApiProperty(description: 'Token expiration timestamp')]
+        public ?string $expiresAt = null,
+    ) {}
+}

+ 60 - 0
packages/Webkul/BagistoApi/src/Dto/EstimateShippingMethodsInput.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use Symfony\Component\Serializer\Annotation\Groups;
+
+class EstimateShippingMethodsInput
+{
+    #[Groups(['mutation'])]
+    public ?string $country = null;
+
+    #[Groups(['mutation'])]
+    public ?string $state = null;
+
+    #[Groups(['mutation'])]
+    public ?string $postcode = null;
+
+    #[Groups(['mutation'])]
+    public ?string $shipping_method = null;
+
+    public function getCountry(): ?string
+    {
+        return $this->country;
+    }
+
+    public function setCountry(?string $country): void
+    {
+        $this->country = $country;
+    }
+
+    public function getState(): ?string
+    {
+        return $this->state;
+    }
+
+    public function setState(?string $state): void
+    {
+        $this->state = $state;
+    }
+
+    public function getPostcode(): ?string
+    {
+        return $this->postcode;
+    }
+
+    public function setPostcode(?string $postcode): void
+    {
+        $this->postcode = $postcode;
+    }
+
+    public function getShipping_method(): ?string
+    {
+        return $this->shipping_method;
+    }
+
+    public function setShipping_method(?string $shipping_method): void
+    {
+        $this->shipping_method = $shipping_method;
+    }
+}

+ 13 - 0
packages/Webkul/BagistoApi/src/Dto/ForgotPasswordInput.php

@@ -0,0 +1,13 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+class ForgotPasswordInput
+{
+    #[ApiProperty(writable: true, readable: false)]
+    #[Groups(['mutation'])]
+    public string $email;
+}

+ 21 - 0
packages/Webkul/BagistoApi/src/Dto/GenerateDownloadLinkInput.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+
+/**
+ * GenerateDownloadLinkInput
+ *
+ * DTO for generating download links for purchased downloadable products
+ */
+class GenerateDownloadLinkInput
+{
+    #[ApiProperty(description: 'ID of the purchased downloadable link')]
+    public int $downloadableLinkPurchasedId;
+
+    public function __construct(int $downloadableLinkPurchasedId)
+    {
+        $this->downloadableLinkPurchasedId = $downloadableLinkPurchasedId;
+    }
+}

+ 32 - 0
packages/Webkul/BagistoApi/src/Dto/LoginInput.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * DTO for customer login via GraphQL mutation input
+ * This explicitly defines all input fields for the GraphQL schema
+ * Note: No 'id' field here - only email and password are input
+ */
+class LoginInput
+{
+    #[Groups(['mutation'])]
+    public string $email;
+
+    #[Groups(['mutation'])]
+    public string $password;
+
+    #[Groups(['mutation'])]
+    public ?string $deviceToken = null;
+
+    public function __construct(
+        string $email = '',
+        string $password = '',
+        ?string $deviceToken = null,
+    ) {
+        $this->email = $email;
+        $this->password = $password;
+        $this->deviceToken = $deviceToken;
+    }
+}

+ 23 - 0
packages/Webkul/BagistoApi/src/Dto/LogoutInput.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * LogoutInput DTO
+ *
+ * Contains input fields for logout mutation.
+ * The token is extracted from the Authorization header, but deviceToken can be passed to remove it from the device tokens table.
+ */
+class LogoutInput
+{
+    #[Groups(['mutation'])]
+    public ?string $deviceToken = null;
+
+    public function __construct(
+        ?string $deviceToken = null,
+    ) {
+        $this->deviceToken = $deviceToken;
+    }
+}

+ 23 - 0
packages/Webkul/BagistoApi/src/Dto/LogoutOutput.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+
+/**
+ * DTO for logout response
+ */
+class LogoutOutput
+{
+    #[ApiProperty(writable: false, readable: true)]
+    public ?bool $success = null;
+
+    #[ApiProperty(writable: false, readable: true)]
+    public ?string $message = null;
+
+    public function __construct(?bool $success = null, ?string $message = null)
+    {
+        $this->success = $success;
+        $this->message = $message;
+    }
+}

+ 34 - 0
packages/Webkul/BagistoApi/src/Dto/MoveToWishlistInput.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use Symfony\Component\Serializer\Annotation\Groups;
+
+class MoveToWishlistInput
+{
+    #[Groups(['mutation'])]
+    public ?array $ids = null;
+
+    #[Groups(['mutation'])]
+    public ?array $qty = null;
+
+    public function getIds(): ?array
+    {
+        return $this->ids;
+    }
+
+    public function setIds(?array $ids): void
+    {
+        $this->ids = $ids;
+    }
+
+    public function getQty(): ?array
+    {
+        return $this->qty;
+    }
+
+    public function setQty(?array $qty): void
+    {
+        $this->qty = $qty;
+    }
+}

+ 25 - 0
packages/Webkul/BagistoApi/src/Dto/MoveWishlistToCartInput.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+
+/**
+ * DTO for moving wishlist items to cart
+ *
+ * Defines the input structure for moving items from wishlist to cart
+ */
+class MoveWishlistToCartInput
+{
+    /**
+     * Wishlist item ID to move to cart
+     */
+    #[ApiProperty(description: 'The numeric ID of the wishlist item to move to cart')]
+    public ?int $wishlistItemId = null;
+
+    /**
+     * Quantity of the item to add to cart
+     */
+    #[ApiProperty(description: 'Quantity of the item to add to cart')]
+    public int $quantity = 1;
+}

+ 27 - 0
packages/Webkul/BagistoApi/src/Dto/MoveWishlistToCartOutput.php

@@ -0,0 +1,27 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * Move Wishlist to Cart Output DTO
+ */
+class MoveWishlistToCartOutput
+{
+    #[ApiProperty(identifier: true)]
+    public ?int $id = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Whether the operation was successful')]
+    public ?bool $success = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'Message describing the result')]
+    public ?string $message = null;
+
+    #[Groups(['query', 'mutation'])]
+    #[ApiProperty(description: 'ID of the wishlist item that was moved')]
+    public ?int $wishlistItemId = null;
+}

+ 42 - 0
packages/Webkul/BagistoApi/src/Dto/PaymentMethodOutput.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+/**
+ * PaymentMethodOutput - GraphQL Output DTO for Payment Methods
+ *
+ * Output for retrieving available payment methods during checkout
+ */
+class PaymentMethodOutput
+{
+    #[Groups(['query'])]
+    #[ApiProperty(identifier: true, readable: true, writable: false)]
+    public ?string $id = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $method = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $title = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $description = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?string $icon = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?array $additionalData = null;
+
+    #[Groups(['query'])]
+    #[ApiProperty(readable: true, writable: false)]
+    public ?bool $isAllowed = null;
+}

+ 0 - 0
packages/Webkul/BagistoApi/src/Dto/RemoveFromCartInput.php


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio