Prechádzať zdrojové kódy

Merge branch 'dev' into dev-rewardPoints

bianjunhui 3 mesiacov pred
rodič
commit
fd179f85ea
100 zmenil súbory, kde vykonal 7463 pridanie a 527 odobranie
  1. 892 0
      app/Console/Commands/SyncCatalogCommand.php
  2. 0 0
      bootstrap/cache/packages.php
  3. 78 68
      bootstrap/cache/services.php
  4. 5 0
      bootstrap/providers.php
  5. 14 0
      composer.json
  6. 5 2
      config/api-platform.php
  7. 21 3
      config/elasticsearch.php
  8. 7 0
      config/logging.php
  9. 58 1
      packages/Longyi/Core/src/Helpers/ProductImage.php
  10. 19 0
      packages/Longyi/Core/src/Models/ProductImage.php
  11. 1 0
      packages/Longyi/Core/src/Models/ProductVariant.php
  12. 13 11
      packages/Longyi/Gift/src/DataGrids/GiftCards/GiftCardsDataGrid.php
  13. 83 34
      packages/Longyi/Gift/src/Http/Controllers/Admin/GiftController.php
  14. 44 61
      packages/Longyi/Gift/src/Http/Controllers/Shop/GiftController.php
  15. 11 7
      packages/Longyi/Gift/src/Models/GiftCards.php
  16. 51 36
      packages/Longyi/Gift/src/Resources/lang/en/app.php
  17. 49 34
      packages/Longyi/Gift/src/Resources/lang/zh_CN/app.php
  18. 0 230
      packages/Longyi/Gift/src/Resources/views/admin/create.blade.php
  19. 414 20
      packages/Longyi/Gift/src/Resources/views/admin/index.blade.php
  20. 5 4
      packages/Longyi/Gift/src/Routes/admin-routes.php
  21. 20 0
      packages/Longyi/Pay/Afterpay/src/Config/payment-methods.php
  22. 195 0
      packages/Longyi/Pay/Afterpay/src/Config/system.php
  23. 89 0
      packages/Longyi/Pay/Afterpay/src/Http/Controllers/AfterpayController.php
  24. 12 0
      packages/Longyi/Pay/Afterpay/src/Http/Controllers/Controller.php
  25. 7 0
      packages/Longyi/Pay/Afterpay/src/Http/routes.php
  26. 294 0
      packages/Longyi/Pay/Afterpay/src/Payment/Afterpay.php
  27. 148 0
      packages/Longyi/Pay/Afterpay/src/Payment/Clearpay.php
  28. 30 0
      packages/Longyi/Pay/Afterpay/src/Providers/AfterpayServiceProvider.php
  29. 9 0
      packages/Longyi/Pay/Afterpay/src/Resources/views/admin/system/readonly-value.blade.php
  30. 76 0
      packages/Longyi/Pay/Afterpay/src/Resources/views/admin/system/update-limits.blade.php
  31. 12 0
      packages/Longyi/Pay/Airwallex/src/Config/payment-methods.php
  32. 82 0
      packages/Longyi/Pay/Airwallex/src/Config/system.php
  33. 66 0
      packages/Longyi/Pay/Airwallex/src/Http/Controllers/AirwallexController.php
  34. 12 0
      packages/Longyi/Pay/Airwallex/src/Http/Controllers/Controller.php
  35. 7 0
      packages/Longyi/Pay/Airwallex/src/Http/routes.php
  36. 262 0
      packages/Longyi/Pay/Airwallex/src/Payment/Airwallex.php
  37. 41 0
      packages/Longyi/Pay/Airwallex/src/Providers/AirwallexServiceProvider.php
  38. 12 0
      packages/Longyi/Pay/Applepay/src/Config/payment-methods.php
  39. 65 0
      packages/Longyi/Pay/Applepay/src/Config/system.php
  40. 48 0
      packages/Longyi/Pay/Applepay/src/Http/Controllers/ApplepayController.php
  41. 12 0
      packages/Longyi/Pay/Applepay/src/Http/Controllers/Controller.php
  42. 8 0
      packages/Longyi/Pay/Applepay/src/Http/routes.php
  43. 269 0
      packages/Longyi/Pay/Applepay/src/Payment/Applepay.php
  44. 41 0
      packages/Longyi/Pay/Applepay/src/Providers/ApplepayServiceProvider.php
  45. 12 0
      packages/Longyi/Pay/AwxAfterpay/src/Config/payment-methods.php
  46. 82 0
      packages/Longyi/Pay/AwxAfterpay/src/Config/system.php
  47. 131 0
      packages/Longyi/Pay/AwxAfterpay/src/Http/Controllers/AfterpayController.php
  48. 12 0
      packages/Longyi/Pay/AwxAfterpay/src/Http/Controllers/Controller.php
  49. 13 0
      packages/Longyi/Pay/AwxAfterpay/src/Http/routes.php
  50. 324 0
      packages/Longyi/Pay/AwxAfterpay/src/Payment/AwxAfterpay.php
  51. 41 0
      packages/Longyi/Pay/AwxAfterpay/src/Providers/AwxAfterpayServiceProvider.php
  52. 112 0
      packages/Longyi/Pay/AwxAfterpay/src/Resources/views/checkout/onepage/paypal-smart-button.blade.php
  53. 16 0
      packages/Longyi/Pay/AwxAfterpay/src/Resources/views/standard-redirect.blade.php
  54. 12 0
      packages/Longyi/Pay/AwxKlarna/src/Config/payment-methods.php
  55. 82 0
      packages/Longyi/Pay/AwxKlarna/src/Config/system.php
  56. 12 0
      packages/Longyi/Pay/AwxKlarna/src/Http/Controllers/Controller.php
  57. 130 0
      packages/Longyi/Pay/AwxKlarna/src/Http/Controllers/KlarnaController.php
  58. 13 0
      packages/Longyi/Pay/AwxKlarna/src/Http/routes.php
  59. 341 0
      packages/Longyi/Pay/AwxKlarna/src/Payment/AwxKlarna.php
  60. 41 0
      packages/Longyi/Pay/AwxKlarna/src/Providers/AwxKlarnaServiceProvider.php
  61. 112 0
      packages/Longyi/Pay/AwxKlarna/src/Resources/views/checkout/onepage/paypal-smart-button.blade.php
  62. 16 0
      packages/Longyi/Pay/AwxKlarna/src/Resources/views/standard-redirect.blade.php
  63. 20 0
      packages/Webkul/Admin/src/Config/acl.php
  64. 6 0
      packages/Webkul/Admin/src/Config/menu.php
  65. 169 0
      packages/Webkul/Admin/src/DataGrids/Settings/ShippingZoneRatesDataGrid.php
  66. 1 1
      packages/Webkul/Admin/src/Http/Controllers/Catalog/ProductController.php
  67. 207 0
      packages/Webkul/Admin/src/Http/Controllers/Settings/ShippingZoneRateController.php
  68. 1 1
      packages/Webkul/Admin/src/Http/Requests/ProductForm.php
  69. 73 0
      packages/Webkul/Admin/src/Resources/lang/en/app.php
  70. 73 0
      packages/Webkul/Admin/src/Resources/lang/zh_CN/app.php
  71. 5 2
      packages/Webkul/Admin/src/Resources/views/catalog/products/edit.blade.php
  72. 0 1
      packages/Webkul/Admin/src/Resources/views/catalog/products/edit/inventories.blade.php
  73. 2 0
      packages/Webkul/Admin/src/Resources/views/components/button/index.blade.php
  74. 945 0
      packages/Webkul/Admin/src/Resources/views/settings/shipping-zone-rates/index.blade.php
  75. 20 0
      packages/Webkul/Admin/src/Routes/settings-routes.php
  76. 2 0
      packages/Webkul/Attribute/src/Models/AttributeFamily.php
  77. 93 1
      packages/Webkul/BagistoApi/README.md
  78. 10 2
      packages/Webkul/BagistoApi/composer.json
  79. 6 3
      packages/Webkul/BagistoApi/config/api-platform-vendor.php
  80. 75 0
      packages/Webkul/BagistoApi/config/bagistoapi.php
  81. 6 1
      packages/Webkul/BagistoApi/src/Console/Commands/InstallApiPlatformCommand.php
  82. 38 0
      packages/Webkul/BagistoApi/src/Console/Commands/OptimizeApiPlatformCommand.php
  83. 35 0
      packages/Webkul/BagistoApi/src/Console/Commands/PruneAuditsCommand.php
  84. 51 0
      packages/Webkul/BagistoApi/src/Console/Commands/WarmApiPlatformCacheCommand.php
  85. 5 0
      packages/Webkul/BagistoApi/src/Contracts/PaymentAttempt.php
  86. 35 0
      packages/Webkul/BagistoApi/src/Database/Migrations/2026_06_02_000000_create_payment_attempts_table.php
  87. 0 0
      packages/Webkul/BagistoApi/src/Database/Migrations/2026_06_09_000000_create_admin_api_audits_table.php
  88. 3 3
      packages/Webkul/BagistoApi/src/Dto/ContactUsInput.php
  89. 17 0
      packages/Webkul/BagistoApi/src/Dto/CreateGdprRequestInput.php
  90. 169 0
      packages/Webkul/BagistoApi/src/Dto/CustomerOrder/CustomerOrderDetailDto.php
  91. 72 0
      packages/Webkul/BagistoApi/src/Dto/CustomerOrder/CustomerOrderListDto.php
  92. 37 0
      packages/Webkul/BagistoApi/src/Dto/CustomerOrder/OrderAddressDto.php
  93. 51 0
      packages/Webkul/BagistoApi/src/Dto/CustomerOrder/OrderItemDto.php
  94. 15 0
      packages/Webkul/BagistoApi/src/Dto/CustomerOrder/OrderPaymentDto.php
  95. 34 0
      packages/Webkul/BagistoApi/src/Dto/CustomerOrder/OrderShipmentDto.php
  96. 17 0
      packages/Webkul/BagistoApi/src/Dto/CustomerOrder/OrderShipmentItemDto.php
  97. 8 1
      packages/Webkul/BagistoApi/src/Dto/CustomerProfileInput.php
  98. 11 0
      packages/Webkul/BagistoApi/src/Dto/DeleteGdprRequestInput.php
  99. 62 0
      packages/Webkul/BagistoApi/src/Dto/PaymentCallbackInput.php
  100. 0 0
      packages/Webkul/BagistoApi/src/Dto/PaymentInitiateInput.php

+ 892 - 0
app/Console/Commands/SyncCatalogCommand.php

@@ -0,0 +1,892 @@
+<?php
+
+namespace App\Console\Commands;
+
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+use Longyi\Core\Models\ProductOption;
+use Longyi\Core\Models\ProductOptionValue;
+use Longyi\Core\Models\ProductVariant;
+use PhpOffice\PhpSpreadsheet\IOFactory;
+use Webkul\Attribute\Models\Attribute;
+use Webkul\Attribute\Models\AttributeFamily;
+use Webkul\Attribute\Models\AttributeOption;
+use Webkul\Attribute\Repositories\AttributeRepository;
+use Webkul\Core\Models\Channel;
+use Webkul\Product\Helpers\Indexers\ElasticSearch;
+use Webkul\Product\Helpers\Indexers\Flat;
+use Webkul\Product\Helpers\Indexers\Price;
+use Webkul\Product\Models\Product;
+
+/**
+ * Import attributes and flexible_variant products from the Magento-style CSV
+ * exports located in storage/sync/.
+ *
+ *  - bagisto_attributes.csv : descriptive Bagisto attributes (catalog attributes)
+ *  - bagisto_products.csv   : configurable rows turned into flexible_variant products
+ *                             (Longyi\Core product_options / product_variants tables)
+ */
+class SyncCatalogCommand extends Command
+{
+    protected $signature = 'catalog:sync
+        {--attributes-only : Only sync attributes}
+        {--products-only : Only sync products}
+        {--limit= : Limit the number of products processed}
+        {--attributes-file= : Absolute/relative path for attributes file (.csv/.xlsx/.xls)}
+        {--products-file= : Absolute/relative path for products file (.csv/.xlsx/.xls)}
+        {--no-index : Skip rebuilding indices at the end}';
+
+    protected $description = 'Sync attributes and flexible_variant products from storage/sync CSV files';
+
+    protected string $attributesCsv;
+
+    protected string $productsCsv;
+
+    protected int $familyId;
+
+    /** General attribute group of the variant_product family. */
+    protected int $generalGroupId = 15;
+
+    protected string $channelCode = 'default';
+
+    protected int $channelId;
+
+    protected string $locale = 'en';
+
+    /** @var array<string, \Webkul\Attribute\Models\Attribute> code => attribute */
+    protected array $familyAttributes = [];
+
+    /** @var int[] Product ids touched during this run (for targeted reindex). */
+    protected array $touchedProductIds = [];
+
+    public function __construct(protected AttributeRepository $attributeRepository)
+    {
+        parent::__construct();
+    }
+
+    public function handle(): int
+    {
+        $this->attributesCsv = $this->resolveInputFile(
+            'bagisto_attributes',
+            $this->option('attributes-file')
+        );
+        $this->productsCsv = $this->resolveInputFile(
+            'bagisto_products',
+            $this->option('products-file')
+        );
+
+        foreach ([
+            'attributes' => $this->attributesCsv,
+            'products'   => $this->productsCsv,
+        ] as $name => $file) {
+            if (! $file || ! is_file($file)) {
+                $this->error("Input file not found for {$name}: ".($file ?: '[empty path]'));
+
+                return self::FAILURE;
+            }
+        }
+
+        $family = AttributeFamily::where('code', 'wigs')->first();
+
+        if (! $family) {
+            $this->error('Attribute family "variant_product" not found.');
+
+            return self::FAILURE;
+        }
+
+        $this->familyId = $family->id;
+
+        $channel = Channel::where('code', $this->channelCode)->first() ?? Channel::first();
+        $this->channelId = $channel->id;
+        $this->channelCode = $channel->code;
+
+        if (! $this->option('products-only')) {
+            $this->syncAttributes();
+        }
+
+        $this->loadFamilyAttributes($family);
+
+        if (! $this->option('attributes-only')) {
+            $this->syncProducts();
+
+            if (! $this->option('no-index')) {
+                $this->reindexProducts();
+            }
+        }
+
+        $this->info('Catalog sync finished.');
+
+        return self::SUCCESS;
+    }
+
+    /* =========================================================================
+     | Attributes
+     * ====================================================================== */
+
+    protected function syncAttributes(): void
+    {
+        $rows = $this->readCsv($this->attributesCsv);
+
+        $this->info('Syncing attributes ('.count($rows).' rows)...');
+
+        $created = 0;
+        $skipped = 0;
+
+        foreach ($rows as $row) {
+            $code = trim((string) ($row['code'] ?? ''));
+
+            if ($code === '') {
+                continue;
+            }
+
+            $existing = Attribute::where('code', $code)->first();
+
+            if ($existing) {
+                $this->ensureAttributeInFamily($existing->id);
+                $skipped++;
+
+                continue;
+            }
+
+            $type = trim((string) $row['type']);
+
+            $data = [
+                'code'              => $code,
+                'en' => ['name' => $row['name'] !== '' ? $row['name'] : $code],
+                'admin_name'        => $row['name'] !== '' ? $row['name'] : $code,
+                'type'              => $type,
+                'is_required'       => (int) ($row['is_required'] ?? 0),
+                'is_filterable'     => (int) ($row['is_filterable'] ?? 0),
+                'is_comparable'     => (int) ($row['is_comparable'] ?? 0),
+                'is_configurable'   => (int) ($row['is_configurable'] ?? 0),
+                'position'          => (int) ($row['position'] ?? 0),
+                'is_user_defined'   => 1,
+                'value_per_locale'  => in_array($type, ['text', 'textarea']) ? 1 : 0,
+                'value_per_channel' => 0,
+            ];
+
+            if (in_array($type, ['select', 'multiselect', 'checkbox']) && trim((string) ($row['options_labels'] ?? '')) !== '') {
+                $labels = array_map('trim', explode('|', $row['options_labels']));
+
+                $options = [];
+
+                foreach (array_values(array_filter($labels, fn ($l) => $l !== '')) as $i => $label) {
+                    $options[] = [
+                        'admin_name' => $label,
+                        'sort_order' => $i,
+                        'en'         => ['label' => $label],
+                    ];
+                }
+
+                $data['options'] = $options;
+            }
+
+            $attribute = $this->attributeRepository->create($data);
+
+            $this->ensureAttributeInFamily($attribute->id);
+
+            $created++;
+        }
+
+        $this->info("Attributes: {$created} created, {$skipped} already existed.");
+    }
+
+    protected function ensureAttributeInFamily(int $attributeId): void
+    {
+        $exists = DB::table('attribute_group_mappings')
+            ->where('attribute_id', $attributeId)
+            ->where('attribute_group_id', $this->generalGroupId)
+            ->exists();
+
+        if ($exists) {
+            return;
+        }
+
+        $position = (int) DB::table('attribute_group_mappings')
+            ->where('attribute_group_id', $this->generalGroupId)
+            ->max('position');
+
+        DB::table('attribute_group_mappings')->insert([
+            'attribute_id'       => $attributeId,
+            'attribute_group_id' => $this->generalGroupId,
+            'position'           => $position + 1,
+        ]);
+    }
+
+    protected function loadFamilyAttributes(AttributeFamily $family): void
+    {
+        $this->familyAttributes = [];
+
+        foreach ($family->custom_attributes()->get() as $attribute) {
+            $this->familyAttributes[$attribute->code] = $attribute;
+        }
+    }
+
+    /* =========================================================================
+     | Products
+     * ====================================================================== */
+
+    protected function syncProducts(): void
+    {
+        $rows = $this->readCsv($this->productsCsv);
+
+        $limit = $this->option('limit') ? (int) $this->option('limit') : null;
+
+        if ($limit) {
+            $rows = array_slice($rows, 0, $limit);
+        }
+
+        $this->info('Syncing '.count($rows).' products...');
+
+        $bar = $this->output->createProgressBar(count($rows));
+        $bar->start();
+
+        foreach ($rows as $row) {
+            try {
+                $this->importProduct($row);
+            } catch (\Throwable $e) {
+                $this->newLine();
+                $this->error('Product '.($row['sku'] ?? '?').' failed: '.$e->getMessage());
+            }
+
+            $bar->advance();
+        }
+
+        $bar->finish();
+        $this->newLine();
+    }
+
+    /**
+     * Reindex only the products touched during this run.
+     *
+     * Done per-product to keep price-index inserts well under MySQL's
+     * 65,535 placeholder limit (a single flexible_variant product can have
+     * hundreds of variants × customer groups). The core Inventory indexer
+     * only covers simple/virtual products, so it is intentionally skipped for
+     * flexible_variant.
+     */
+    protected function reindexProducts(): void
+    {
+        $ids = array_values(array_unique($this->touchedProductIds));
+
+        if (empty($ids)) {
+            return;
+        }
+
+        $this->info('Reindexing '.count($ids).' products (price/flat'.($this->elasticEnabled() ? '/elastic' : '').')...');
+
+        /** @var \Webkul\Product\Helpers\Indexers\Price $priceIndexer */
+        $priceIndexer = app(Price::class);
+        /** @var \Webkul\Product\Helpers\Indexers\Flat $flatIndexer */
+        $flatIndexer = app(Flat::class);
+        $elasticIndexer = $this->elasticEnabled() ? app(ElasticSearch::class) : null;
+
+        $relations = [
+            'attribute_family',
+            'attribute_values',
+            'channels',
+            'price_indices',
+            'customer_group_prices',
+            'catalog_rule_prices',
+            'flexibleVariants',
+            'flexibleVariants.price_indices',
+            'flexibleVariants.customer_group_prices',
+        ];
+
+        $bar = $this->output->createProgressBar(count($ids));
+        $bar->start();
+
+        foreach (array_chunk($ids, 20) as $chunk) {
+            $products = Product::with($relations)->whereIn('id', $chunk)->get();
+
+            foreach ($products as $product) {
+                try {
+                    $priceIndexer->reindexRow($product);
+                    $flatIndexer->reindexRow($product);
+                    $elasticIndexer?->reindexRow($product);
+                } catch (\Throwable $e) {
+                    $this->newLine();
+                    $this->error('Reindex failed for product '.$product->sku.': '.$e->getMessage());
+                }
+
+                $bar->advance();
+            }
+        }
+
+        $bar->finish();
+        $this->newLine();
+    }
+
+    protected function elasticEnabled(): bool
+    {
+        return core()->getConfigData('catalog.products.search.engine') === 'elastic';
+    }
+
+    protected function importProduct(array $row): void
+    {
+        $sku = trim((string) $row['sku']);
+
+        if ($sku === '') {
+            return;
+        }
+
+        $product = Product::firstOrNew(['sku' => $sku]);
+        $product->type = 'flexible_variant';
+        $product->attribute_family_id = $this->familyId;
+        $product->save();
+
+        $this->touchedProductIds[] = $product->id;
+
+        DB::table('product_channels')->updateOrInsert([
+            'product_id' => $product->id,
+            'channel_id' => $this->channelId,
+        ]);
+
+        $this->saveAttributeValues($product, $row);
+        $this->saveInventory($product, $row);
+
+        $this->saveImages($product, $row);
+
+        DB::transaction(function () use ($product, $row) {
+            $this->saveOptionsAndVariants($product, $row);
+        });
+    }
+
+    protected function saveAttributeValues(Product $product, array $row): void
+    {
+        $row = $this->applyDefaultProductAttributeValues($row);
+
+        $values = [];
+
+        foreach ($this->familyAttributes as $code => $attribute) {
+            if (! array_key_exists($code, $row)) {
+                continue;
+            }
+
+            $rawValue = $row[$code];
+
+            if ($rawValue === null || $rawValue === '') {
+                continue;
+            }
+
+            $columnValue = $this->castAttributeValue($attribute, $rawValue);
+
+            if ($columnValue === null) {
+                continue;
+            }
+
+            $valueRow = array_fill_keys(array_values($attribute->attributeTypeFields), null);
+
+            $valueRow['json_value'] = null;
+            $valueRow[$attribute->column_name] = $columnValue;
+            $valueRow['attribute_id'] = $attribute->id;
+            $valueRow['product_id'] = $product->id;
+            $valueRow['channel'] = $attribute->value_per_channel ? $this->channelCode : null;
+            $valueRow['locale'] = $attribute->value_per_locale ? $this->locale : null;
+            $valueRow['unique_id'] = implode('|', array_filter([
+                $valueRow['channel'],
+                $valueRow['locale'],
+                $valueRow['product_id'],
+                $valueRow['attribute_id'],
+            ]));
+
+            $values[$valueRow['unique_id']] = $valueRow;
+        }
+
+        if (empty($values)) {
+            return;
+        }
+
+        DB::table('product_attribute_values')->upsert(
+            array_values($values),
+            ['unique_id'],
+            ['text_value', 'boolean_value', 'integer_value', 'float_value', 'datetime_value', 'date_value', 'json_value']
+        );
+    }
+
+    protected function applyDefaultProductAttributeValues(array $row): array
+    {
+        foreach ([
+            'visible_individually' => 1,
+            'manage_stock' => 1,
+        ] as $code => $defaultValue) {
+            if (
+                ! array_key_exists($code, $row)
+                || $row[$code] === null
+                || (is_string($row[$code]) && trim($row[$code]) === '')
+            ) {
+                $row[$code] = $defaultValue;
+            }
+        }
+
+        return $row;
+    }
+
+    protected function saveInventory(Product $product, array $row): void
+    {
+        $qty = $row['qty'] ?? 9999;
+
+        if ($qty === null || (is_string($qty) && trim($qty) === '')) {
+            $qty = 9999;
+        }
+
+        DB::table('product_inventories')->updateOrInsert(
+            [
+                'product_id' => $product->id,
+                'inventory_source_id' => 1,
+                'vendor_id' => 0,
+            ],
+            ['qty' => (int) $qty]
+        );
+    }
+
+    /**
+     * @return mixed
+     */
+    protected function castAttributeValue(Attribute $attribute, $value)
+    {
+        switch ($attribute->type) {
+            case 'boolean':
+                return (int) (bool) (is_numeric($value) ? (int) $value : $value);
+
+            case 'price':
+                return (float) $value;
+
+            case 'select':
+                $option = AttributeOption::where('attribute_id', $attribute->id)
+                    ->where('admin_name', $value)
+                    ->first();
+
+                return $option?->id;
+
+            case 'multiselect':
+                $ids = [];
+
+                foreach (array_map('trim', explode(',', (string) $value)) as $label) {
+                    if ($label === '') {
+                        continue;
+                    }
+
+                    $option = AttributeOption::where('attribute_id', $attribute->id)
+                        ->where('admin_name', $label)
+                        ->first();
+
+                    if ($option) {
+                        $ids[] = $option->id;
+                    }
+                }
+
+                return empty($ids) ? null : implode(',', $ids);
+
+            default:
+                return $value;
+        }
+    }
+
+    protected function saveImages(Product $product, array $row): void
+    {
+        $urls = [];
+
+        if (! empty($row['base_image'])) {
+            $urls[] = trim($row['base_image']);
+        }
+
+        if (! empty($row['additional_images'])) {
+            foreach (explode('|', $row['additional_images']) as $url) {
+                $url = trim($url);
+
+                if ($url !== '') {
+                    $urls[] = $url;
+                }
+            }
+        }
+
+        $urls = array_values(array_unique($urls));
+
+        if (empty($urls)) {
+            return;
+        }
+
+        // Rebuild image set for idempotent re-runs.
+        DB::table('product_images')->where('product_id', $product->id)->delete();
+
+        $records = [];
+
+        foreach ($urls as $position => $url) {
+            $records[] = [
+                'type'          => 'images',
+                'path'          => $url,
+                'product_id'    => $product->id,
+                'position'      => $position + 1,
+                'is_base_image' => $position === 0 ? 1 : 0,
+            ];
+        }
+
+        DB::table('product_images')->insert($records);
+    }
+
+    /* =========================================================================
+     | Flexible variant options & variants
+     * ====================================================================== */
+
+    protected function saveOptionsAndVariants(Product $product, array $row): void
+    {
+        $superCodes = array_values(array_filter(array_map('trim', explode(',', (string) ($row['super_attributes'] ?? '')))));
+
+        if (empty($superCodes)) {
+            return;
+        }
+
+        $optionsMeta = json_decode((string) ($row['options'] ?? '[]'), true) ?: [];
+        $variants = json_decode((string) ($row['variants'] ?? '[]'), true) ?: [];
+
+        // Clean previous flexible variant data for idempotent re-runs.
+        $this->clearVariantData($product);
+
+        /**
+         * For each super-attribute (dimension) create a product option and its
+         * values. labelToValueId[dimensionIndex][label] => product_option_value_id
+         *
+         * @var array<int, array<string, int>> $labelToValueId
+         */
+        $labelToValueId = [];
+
+        $syncOptions = [];
+
+        foreach ($superCodes as $index => $code) {
+            $meta = $optionsMeta[$index] ?? [];
+
+            $optionLabel = $meta['title'] ?? Str::title(str_replace('_', ' ', $code));
+
+            /**
+             * product_options.code is globally unique, so options are SHARED
+             * across products. Reuse an existing option for this dimension or
+             * create it once.
+             */
+            $option = ProductOption::firstOrCreate(
+                ['code' => $code],
+                ['label' => $optionLabel, 'type' => 'select', 'position' => $index]
+            );
+
+            $syncOptions[$option->id] = ['position' => $index, 'is_required' => true];
+
+            // Existing values for this (shared) option, keyed by label.
+            $existing = ProductOptionValue::where('product_option_id', $option->id)
+                ->get()
+                ->keyBy('label');
+
+            foreach ($existing as $label => $value) {
+                $labelToValueId[$index][$label] = $value->id;
+            }
+
+            // Collect distinct labels for this dimension: prefer the options JSON
+            // value list (gives sku/order), then add any labels seen in variants.
+            $orderedLabels = [];
+            $valueSku = [];
+
+            foreach (($meta['values'] ?? []) as $valueMeta) {
+                $label = (string) ($valueMeta['title'] ?? '');
+
+                if ($label === '' || array_key_exists($label, $valueSku)) {
+                    continue;
+                }
+
+                $orderedLabels[] = $label;
+                $valueSku[$label] = (string) ($valueMeta['sku'] ?? '');
+            }
+
+            foreach ($variants as $variant) {
+                $label = $variant[(string) $index] ?? null;
+
+                if ($label !== null && ! array_key_exists($label, $valueSku)) {
+                    $orderedLabels[] = $label;
+                    $valueSku[$label] = '';
+                }
+            }
+
+            $nextPosition = (int) (ProductOptionValue::where('product_option_id', $option->id)->max('position'));
+
+            foreach ($orderedLabels as $label) {
+                if (isset($labelToValueId[$index][$label])) {
+                    continue;
+                }
+
+                $valueCode = $valueSku[$label] !== '' ? $valueSku[$label] : Str::slug($label);
+
+                if ($valueCode === '') {
+                    $valueCode = 'value-'.($nextPosition + 1);
+                }
+
+                $value = ProductOptionValue::create([
+                    'product_option_id' => $option->id,
+                    'label'             => $label,
+                    'code'              => $valueCode,
+                    'position'          => ++$nextPosition,
+                ]);
+
+                $labelToValueId[$index][$label] = $value->id;
+            }
+        }
+
+        $product->options()->sync($syncOptions);
+
+        $this->saveVariants($product, $row, $superCodes, $variants, $labelToValueId);
+    }
+
+    protected function saveVariants(Product $product, array $row, array $superCodes, array $variants, array $labelToValueId): void
+    {
+        if (empty($variants)) {
+            return;
+        }
+
+        $parentName = (string) ($row['name'] ?? '');
+
+        $variantRecords = [];
+        $now = now();
+
+        foreach ($variants as $sortOrder => $variant) {
+            $sku = trim((string) ($variant['sku'] ?? ''));
+
+            if ($sku === '') {
+                continue;
+            }
+
+            $variantRecords[] = [
+                'product_id' => $product->id,
+                'sku'        => $sku,
+                'name'       => $parentName !== '' ? $parentName : null,
+                'price'      => (float) ($variant['price'] ?? 0),
+                'quantity'   => (int) ($variant['qty'] ?? 0),
+                'status'     => (int) ($variant['in_stock'] ?? 1) ? 1 : 1,
+                'sort_order' => $sortOrder,
+                'created_at' => $now,
+                'updated_at' => $now,
+            ];
+        }
+
+        if (empty($variantRecords)) {
+            return;
+        }
+
+        foreach (array_chunk($variantRecords, 500) as $chunk) {
+            ProductVariant::insert($chunk);
+        }
+
+        // Map sku => variant id for the pivot inserts.
+        $variantIdBySku = ProductVariant::where('product_id', $product->id)
+            ->pluck('id', 'sku')
+            ->toArray();
+
+        $pivotRecords = [];
+
+        foreach ($variants as $variant) {
+            $sku = trim((string) ($variant['sku'] ?? ''));
+            $variantId = $variantIdBySku[$sku] ?? null;
+
+            if (! $variantId) {
+                continue;
+            }
+
+            foreach ($superCodes as $index => $code) {
+                $label = $variant[(string) $index] ?? null;
+
+                if ($label === null) {
+                    continue;
+                }
+
+                $valueId = $labelToValueId[$index][$label] ?? null;
+
+                if (! $valueId) {
+                    continue;
+                }
+
+                $pivotRecords[] = [
+                    'product_variant_id'      => $variantId,
+                    'product_option_value_id' => $valueId,
+                    'created_at'              => $now,
+                    'updated_at'              => $now,
+                ];
+            }
+        }
+
+        foreach (array_chunk($pivotRecords, 1000) as $chunk) {
+            DB::table('product_variant_option_values')->insert($chunk);
+        }
+    }
+
+    /**
+     * Remove this product's variants and its option associations so the command
+     * can be safely re-run. Shared product_options / product_option_values are
+     * intentionally left intact (they are global and reused by other products).
+     */
+    protected function clearVariantData(Product $product): void
+    {
+        $variantIds = ProductVariant::withTrashed()
+            ->where('product_id', $product->id)
+            ->pluck('id')
+            ->toArray();
+
+        if (! empty($variantIds)) {
+            DB::table('product_variant_option_values')
+                ->whereIn('product_variant_id', $variantIds)
+                ->delete();
+
+            DB::table('product_variant_images')
+                ->whereIn('product_variant_id', $variantIds)
+                ->delete();
+
+            ProductVariant::withTrashed()->whereIn('id', $variantIds)->forceDelete();
+        }
+
+        $product->options()->detach();
+    }
+
+    /* =========================================================================
+     | File helper
+     * ====================================================================== */
+
+    /**
+     * Resolve input file path from:
+     * 1) explicit option path
+     * 2) storage/sync/<base>.csv
+     * 3) storage/sync/<base>.xlsx
+     * 4) storage/sync/<base>.xls
+     */
+    protected function resolveInputFile(string $baseName, ?string $customPath = null): ?string
+    {
+        if (is_string($customPath) && trim($customPath) !== '') {
+            $path = trim($customPath);
+
+            if (! Str::startsWith($path, ['/'])) {
+                $path = base_path($path);
+            }
+
+            return $path;
+        }
+
+        foreach (['csv', 'xlsx', 'xls'] as $ext) {
+            $path = storage_path("sync/{$baseName}.{$ext}");
+
+            if (is_file($path)) {
+                return $path;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Read CSV/XLSX/XLS into associative rows keyed by header.
+     *
+     * @return array<int, array<string, string|null>>
+     */
+    protected function readCsv(string $path): array
+    {
+        $extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
+
+        if (in_array($extension, ['xlsx', 'xls'], true)) {
+            return $this->readExcel($path);
+        }
+
+        return $this->readCsvFile($path);
+    }
+
+    /**
+     * @return array<int, array<string, string|null>>
+     */
+    protected function readCsvFile(string $path): array
+    {
+        $rows = [];
+
+        $handle = fopen($path, 'r');
+
+        if ($handle === false) {
+            return $rows;
+        }
+
+        $header = fgetcsv($handle);
+
+        if ($header === false) {
+            fclose($handle);
+
+            return $rows;
+        }
+
+        $header = array_map(fn ($h) => trim((string) $h), $header);
+
+        while (($data = fgetcsv($handle)) !== false) {
+            if ($data === [null] || $data === false) {
+                continue;
+            }
+
+            $row = [];
+
+            foreach ($header as $i => $key) {
+                if ($key === '') {
+                    continue;
+                }
+
+                $row[$key] = $data[$i] ?? null;
+            }
+
+            // Skip fully empty lines.
+            if (count(array_filter($row, fn ($v) => $v !== null && $v !== '')) === 0) {
+                continue;
+            }
+
+            $rows[] = $row;
+        }
+
+        fclose($handle);
+
+        return $rows;
+    }
+
+    /**
+     * @return array<int, array<string, string|null>>
+     */
+    protected function readExcel(string $path): array
+    {
+        $rows = [];
+
+        $spreadsheet = IOFactory::load($path);
+        $sheetRows = $spreadsheet
+            ->getActiveSheet()
+            ->toArray(null, false, false, false);
+
+        if (empty($sheetRows)) {
+            return $rows;
+        }
+
+        $header = array_map(
+            fn ($h) => trim((string) $h),
+            array_shift($sheetRows) ?: []
+        );
+
+        foreach ($sheetRows as $data) {
+            if (! is_array($data)) {
+                continue;
+            }
+
+            $row = [];
+
+            foreach ($header as $i => $key) {
+                if ($key === '') {
+                    continue;
+                }
+
+                $value = $data[$i] ?? null;
+                $row[$key] = $value === null ? null : (string) $value;
+            }
+
+            if (count(array_filter($row, fn ($v) => $v !== null && $v !== '')) === 0) {
+                continue;
+            }
+
+            $rows[] = $row;
+        }
+
+        return $rows;
+    }
+}

+ 0 - 0
bootstrap/cache/packages.php


+ 78 - 68
bootstrap/cache/services.php

@@ -60,40 +60,45 @@
     56 => 'Longyi\\RewardPoints\\Providers\\RewardPointsServiceProvider',
     57 => 'Longyi\\Member\\Providers\\MemberServiceProvider',
     58 => 'Longyi\\Gift\\Providers\\GiftServiceProvider',
-    59 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
-    60 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
-    61 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
-    62 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
-    63 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
-    64 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
-    65 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
-    66 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
-    67 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
-    68 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
-    69 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
-    70 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
-    71 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
-    72 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
-    73 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
-    74 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
-    75 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
-    76 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
-    77 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
-    78 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
-    79 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
-    80 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
-    81 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
-    82 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
-    83 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
-    84 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
-    85 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
-    86 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
-    87 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
-    88 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
-    89 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
-    90 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
-    91 => 'Webkul\\User\\Providers\\UserServiceProvider',
-    92 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
+    59 => 'Longyi\\Pay\\AwxAfterpay\\Providers\\AwxAfterpayServiceProvider',
+    60 => 'Longyi\\Pay\\AwxKlarna\\Providers\\AwxKlarnaServiceProvider',
+    61 => 'Longyi\\Pay\\Applepay\\Providers\\ApplepayServiceProvider',
+    62 => 'Longyi\\Pay\\Airwallex\\Providers\\AirwallexServiceProvider',
+    63 => 'Longyi\\Pay\\Afterpay\\Providers\\AfterpayServiceProvider',
+    64 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
+    65 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
+    66 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
+    67 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
+    68 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
+    69 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
+    70 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
+    71 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
+    72 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
+    73 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
+    74 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
+    75 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
+    76 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
+    77 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
+    78 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
+    79 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
+    80 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
+    81 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
+    82 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
+    83 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
+    84 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
+    85 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
+    86 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
+    87 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
+    88 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
+    89 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
+    90 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
+    91 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
+    92 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
+    93 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
+    94 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
+    95 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
+    96 => 'Webkul\\User\\Providers\\UserServiceProvider',
+    97 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
   ),
   'eager' => 
   array (
@@ -139,40 +144,45 @@
     39 => 'Longyi\\RewardPoints\\Providers\\RewardPointsServiceProvider',
     40 => 'Longyi\\Member\\Providers\\MemberServiceProvider',
     41 => 'Longyi\\Gift\\Providers\\GiftServiceProvider',
-    42 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
-    43 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
-    44 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
-    45 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
-    46 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
-    47 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
-    48 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
-    49 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
-    50 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
-    51 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
-    52 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
-    53 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
-    54 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
-    55 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
-    56 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
-    57 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
-    58 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
-    59 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
-    60 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
-    61 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
-    62 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
-    63 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
-    64 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
-    65 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
-    66 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
-    67 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
-    68 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
-    69 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
-    70 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
-    71 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
-    72 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
-    73 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
-    74 => 'Webkul\\User\\Providers\\UserServiceProvider',
-    75 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
+    42 => 'Longyi\\Pay\\AwxAfterpay\\Providers\\AwxAfterpayServiceProvider',
+    43 => 'Longyi\\Pay\\AwxKlarna\\Providers\\AwxKlarnaServiceProvider',
+    44 => 'Longyi\\Pay\\Applepay\\Providers\\ApplepayServiceProvider',
+    45 => 'Longyi\\Pay\\Airwallex\\Providers\\AirwallexServiceProvider',
+    46 => 'Longyi\\Pay\\Afterpay\\Providers\\AfterpayServiceProvider',
+    47 => 'Webkul\\Attribute\\Providers\\AttributeServiceProvider',
+    48 => 'Webkul\\BookingProduct\\Providers\\BookingProductServiceProvider',
+    49 => 'Webkul\\CMS\\Providers\\CMSServiceProvider',
+    50 => 'Webkul\\CartRule\\Providers\\CartRuleServiceProvider',
+    51 => 'Webkul\\CatalogRule\\Providers\\CatalogRuleServiceProvider',
+    52 => 'Webkul\\Category\\Providers\\CategoryServiceProvider',
+    53 => 'Webkul\\Checkout\\Providers\\CheckoutServiceProvider',
+    54 => 'Webkul\\Core\\Providers\\CoreServiceProvider',
+    55 => 'Webkul\\Core\\Providers\\EnvValidatorServiceProvider',
+    56 => 'Webkul\\Customer\\Providers\\CustomerServiceProvider',
+    57 => 'Webkul\\DataGrid\\Providers\\DataGridServiceProvider',
+    58 => 'Webkul\\DataTransfer\\Providers\\DataTransferServiceProvider',
+    59 => 'Webkul\\DebugBar\\Providers\\DebugBarServiceProvider',
+    60 => 'Webkul\\FPC\\Providers\\FPCServiceProvider',
+    61 => 'Webkul\\GDPR\\Providers\\GDPRServiceProvider',
+    62 => 'Webkul\\Installer\\Providers\\InstallerServiceProvider',
+    63 => 'Webkul\\Inventory\\Providers\\InventoryServiceProvider',
+    64 => 'Webkul\\MagicAI\\Providers\\MagicAIServiceProvider',
+    65 => 'Webkul\\Marketing\\Providers\\MarketingServiceProvider',
+    66 => 'Webkul\\Notification\\Providers\\NotificationServiceProvider',
+    67 => 'Webkul\\Payment\\Providers\\PaymentServiceProvider',
+    68 => 'Webkul\\Paypal\\Providers\\PaypalServiceProvider',
+    69 => 'Webkul\\Product\\Providers\\ProductServiceProvider',
+    70 => 'Webkul\\Rule\\Providers\\RuleServiceProvider',
+    71 => 'Webkul\\Sales\\Providers\\SalesServiceProvider',
+    72 => 'Webkul\\Shipping\\Providers\\ShippingServiceProvider',
+    73 => 'Webkul\\Shop\\Providers\\ShopServiceProvider',
+    74 => 'Webkul\\Sitemap\\Providers\\SitemapServiceProvider',
+    75 => 'Webkul\\SocialLogin\\Providers\\SocialLoginServiceProvider',
+    76 => 'Webkul\\SocialShare\\Providers\\SocialShareServiceProvider',
+    77 => 'Webkul\\Tax\\Providers\\TaxServiceProvider',
+    78 => 'Webkul\\Theme\\Providers\\ThemeServiceProvider',
+    79 => 'Webkul\\User\\Providers\\UserServiceProvider',
+    80 => 'Webkul\\BagistoApi\\Providers\\BagistoApiServiceProvider',
   ),
   'deferred' => 
   array (

+ 5 - 0
bootstrap/providers.php

@@ -17,6 +17,11 @@ return [
     Longyi\RewardPoints\Providers\RewardPointsServiceProvider::class,
     Longyi\Member\Providers\MemberServiceProvider::class,
     Longyi\Gift\Providers\GiftServiceProvider::class,
+    Longyi\Pay\AwxAfterpay\Providers\AwxAfterpayServiceProvider::class,
+    Longyi\Pay\AwxKlarna\Providers\AwxKlarnaServiceProvider::class,
+    Longyi\Pay\Applepay\Providers\ApplepayServiceProvider::class,
+    Longyi\Pay\Airwallex\Providers\AirwallexServiceProvider::class,
+    Longyi\Pay\Afterpay\Providers\AfterpayServiceProvider::class,
     Webkul\Attribute\Providers\AttributeServiceProvider::class,
     Webkul\BookingProduct\Providers\BookingProductServiceProvider::class,
     Webkul\CMS\Providers\CMSServiceProvider::class,

+ 14 - 0
composer.json

@@ -17,8 +17,17 @@
         "ext-pdo": "*",
         "ext-pdo_mysql": "*",
         "ext-tokenizer": "*",
+        "api-platform/documentation": "v4.3.1",
         "api-platform/graphql": "v4.2.3",
+        "api-platform/hydra": "v4.3.1",
+        "api-platform/json-api": "v4.3.1",
+        "api-platform/json-schema": "v4.3.1",
+        "api-platform/jsonld": "v4.3.1",
         "api-platform/laravel": "v4.1.25",
+        "api-platform/metadata": "v4.3.1",
+        "api-platform/openapi": "v4.3.1",
+        "api-platform/serializer": "v4.3.1",
+        "api-platform/state": "v4.3.1",
         "astrotomic/laravel-translatable": "^11.0.0",
         "bagisto/bagisto-package-generator": "^2.1",
         "bagisto/image-cache": "dev-master",
@@ -75,6 +84,11 @@
             "Longyi\\RewardPoints\\": "packages/Longyi/RewardPoints/src/",
             "Longyi\\Member\\": "packages/Longyi/Member/src/",
             "Longyi\\Gift\\": "packages/Longyi/Gift/src/",
+            "Longyi\\Pay\\Airwallex\\": "packages/Longyi/Pay/Airwallex/src/",
+            "Longyi\\Pay\\Applepay\\": "packages/Longyi/Pay/Applepay/src/",
+            "Longyi\\Pay\\AwxKlarna\\": "packages/Longyi/Pay/AwxKlarna/src/",
+            "Longyi\\Pay\\AwxAfterpay\\": "packages/Longyi/Pay/AwxAfterpay/src/",
+            "Longyi\\Pay\\Afterpay\\": "packages/Longyi/Pay/Afterpay/src/",
             "Webkul\\Admin\\": "packages/Webkul/Admin/src",
             "Webkul\\Attribute\\": "packages/Webkul/Attribute/src",
             "Webkul\\BookingProduct\\": "packages/Webkul/BookingProduct/src",

+ 5 - 2
config/api-platform.php

@@ -44,12 +44,15 @@ return [
             'Webkul\BagistoApi\Http\Middleware\VerifyStorefrontKey',
             'Webkul\BagistoApi\Http\Middleware\BagistoApiDocumentationMiddleware',
             'Webkul\BagistoApi\Http\Middleware\ForceApiJson',
+            'Webkul\BagistoApi\Http\Middleware\PaginationHeaders',
             'Spatie\ResponseCache\Middlewares\CacheResponse',
         ],
     ],
 
     'resources' => [
         base_path('packages/Webkul/BagistoApi/src/Models/'),
+        base_path('packages/Webkul/BagistoApi/src/Dto/ProductDetail/'),
+        base_path('packages/Webkul/BagistoApi/src/Dto/CustomerOrder/'),
     ],
 
     'formats' => [
@@ -73,7 +76,7 @@ return [
         'pagination_enabled'                => true,
         'pagination_partial'                => false,
         'pagination_client_enabled'         => false,
-        'pagination_client_items_per_page'  => false,
+        'pagination_client_items_per_page'  => true,
         'pagination_client_partial'         => false,
         'pagination_items_per_page'         => 10,
         'pagination_maximum_items_per_page' => 50,
@@ -84,7 +87,7 @@ return [
     'pagination' => [
         'page_parameter_name'           => 'page',
         'enabled_parameter_name'        => 'pagination',
-        'items_per_page_parameter_name' => 'itemsPerPage',
+        'items_per_page_parameter_name' => 'per_page',
         'partial_parameter_name'        => 'partial',
     ],
 

+ 21 - 3
config/elasticsearch.php

@@ -54,12 +54,30 @@ return [
     /**
      * CA Bundle
      *
-     * If you have the http_ca.crt certificate copied during the start of Elasticsearch
-     * then the path here
+     * OrbStack (*.orb.local) uses its own root CA; direct ES uses http_ca.crt.
+     * Override with ELASTICSEARCH_CA_BUNDLE in .env when needed.
      *
      * @see https://www.elastic.co/guide/en/elasticsearch/client/php-api/current/connecting.html#auth-http
      */
-    'caBundle' => null,
+    'caBundle' => (function () {
+        $bundle = env('ELASTICSEARCH_CA_BUNDLE');
+
+        if ($bundle && ! str_starts_with($bundle, '/')) {
+            $bundle = base_path($bundle);
+        }
+
+        if ($bundle && is_readable($bundle)) {
+            return $bundle;
+        }
+
+        foreach ([public_path('orbstack_ca.crt'), public_path('elastic_ca.crt')] as $candidate) {
+            if (is_readable($candidate)) {
+                return $candidate;
+            }
+        }
+
+        return null;
+    })(),
 
     /**
      * Retries

+ 7 - 0
config/logging.php

@@ -71,6 +71,13 @@ return [
             'days'                 => 14,
             'replace_placeholders' => true,
         ],
+        'payment' => [
+            'driver'               => 'daily',
+            'path'                 => storage_path('logs/payment.log'),
+            'level'                => 'info',
+            'days'                 => 14,
+            'replace_placeholders' => true,
+        ],
         'daily' => [
             'driver'               => 'daily',
             'path'                 => storage_path('logs/laravel.log'),

+ 58 - 1
packages/Longyi/Core/src/Helpers/ProductImage.php

@@ -2,6 +2,7 @@
 
 namespace Longyi\Core\Helpers;
 
+use Illuminate\Support\Str;
 use Webkul\Product\ProductImage as BaseProductImage;
 
 /**
@@ -12,10 +13,57 @@ use Webkul\Product\ProductImage as BaseProductImage;
  *   - getSmallImage($product)     → image with is_small_image = true (falls back to first)
  *   - getThumbnailImage($product) → image with is_thumbnail = true  (falls back to first)
  *
- * Also overrides getProductBaseImage() to honour the is_base_image flag.
+ * Also overrides getProductBaseImage() to honour the is_base_image flag, and
+ * supports remote/absolute image URLs stored directly in product_images.path.
  */
 class ProductImage extends BaseProductImage
 {
+    /**
+     * Resolve gallery images, honouring remote/absolute URLs stored in the
+     * `path` column (which Storage::has() / the local cache resizer can't serve).
+     *
+     * @param  \Webkul\Product\Contracts\Product  $product
+     * @return array
+     */
+    public function getGalleryImages($product)
+    {
+        if (! $product) {
+            return [];
+        }
+
+        $images = [];
+
+        foreach ($product->images as $image) {
+            if (! $this->isAbsoluteUrl($image->path) && ! \Illuminate\Support\Facades\Storage::has($image->path)) {
+                continue;
+            }
+
+            $images[] = $this->getCachedImageUrlsPublic($image->path);
+        }
+
+        if (
+            ! $product->parent_id
+            && ! count($images)
+            && ! count($product->videos ?? [])
+        ) {
+            $images[] = $this->getFallbackImageUrlsPublic();
+        }
+
+        if (empty($images) && $product->parent) {
+            $images = $this->getGalleryImages($product->parent);
+        }
+
+        return $images;
+    }
+
+    /**
+     * Determine whether a path is an absolute (remote) URL.
+     */
+    protected function isAbsoluteUrl(?string $path): bool
+    {
+        return is_string($path) && Str::startsWith($path, ['http://', 'https://']);
+    }
+
     /**
      * Get the image designated as the base image.
      * Falls back to the first image if none is explicitly set.
@@ -116,6 +164,15 @@ class ProductImage extends BaseProductImage
      */
     protected function getCachedImageUrlsPublic(string $path): array
     {
+        if ($this->isAbsoluteUrl($path)) {
+            return [
+                'small_image_url'    => $path,
+                'medium_image_url'   => $path,
+                'large_image_url'    => $path,
+                'original_image_url' => $path,
+            ];
+        }
+
         if (! $this->isDriverLocalPublic()) {
             return [
                 'small_image_url'    => \Illuminate\Support\Facades\Storage::url($path),

+ 19 - 0
packages/Longyi/Core/src/Models/ProductImage.php

@@ -3,6 +3,7 @@
 namespace Longyi\Core\Models;
 
 use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
 use Webkul\Product\Models\ProductImage as BaseProductImage;
 use Webkul\Product\Models\ProductProxy;
 
@@ -78,6 +79,24 @@ class ProductImage extends BaseProductImage
         return in_array($role, $this->getRolesAttribute(), true);
     }
 
+    /**
+     * Get image url for the product image.
+     *
+     * Supports remote/absolute URLs stored directly in the `path` column
+     * (e.g. imported product images) by returning them verbatim instead of
+     * prefixing the local storage path.
+     *
+     * @return string
+     */
+    public function url()
+    {
+        if (is_string($this->path) && Str::startsWith($this->path, ['http://', 'https://'])) {
+            return $this->path;
+        }
+
+        return Storage::url($this->path);
+    }
+
     /**
      * Scope: filter images that are designated as base image.
      */

+ 1 - 0
packages/Longyi/Core/src/Models/ProductVariant.php

@@ -246,3 +246,4 @@ class ProductVariant extends Model implements ProductVariantContract, HasMedia
         return $query->where('status', true)->where('quantity', '>', 0);
     }
 }
+

+ 13 - 11
packages/Longyi/Gift/src/DataGrids/GiftCards/GiftCardsDataGrid.php

@@ -18,7 +18,7 @@ class GiftCardsDataGrid extends DataGrid
         $queryBuilder = DB::table('gift_cards')
             ->leftJoin('customers', 'gift_cards.customer_id', '=', 'customers.id')
             ->leftJoin('gift_card_usage_logs as gift_log', function ($leftJoin) {
-                $leftJoin->on('gift_log.customer_id', '=', 'gift_cards.customer_id')
+                $leftJoin->on('gift_log.giftcard_number', '=', 'gift_cards.giftcard_number')
                     ->where('gift_log.action_type', GiftCardUsageLog::ACTION_ADD);
             })
             ->select(
@@ -176,19 +176,21 @@ class GiftCardsDataGrid extends DataGrid
      */
     public function prepareActions()
     {
-//        if (bouncer()->hasPermission('gift.edit')) {
-//            $this->addAction([
-//                'icon'   => 'icon-edit',
-//                'title'  => trans('gift::app.admin.datagrid.edit'),
-//                'method' => 'GET',
-//                'url'    => function ($row) {
-//                    return route('admin.gift.edit', $row->id);
-//                },
-//            ]);
-//        }
+        if (bouncer()->hasPermission('gift.edit')) {
+            $this->addAction([
+                'index'  => 'edit',
+                'icon'   => 'icon-edit',
+                'title'  => trans('gift::app.admin.datagrid.edit'),
+                'method' => 'GET',
+                'url'    => function ($row) {
+                    return route('admin.gift.edit', $row->id);
+                },
+            ]);
+        }
 
         if (bouncer()->hasPermission('gift.delete')) {
             $this->addAction([
+                'index'  => 'delete',
                 'icon'   => 'icon-delete',
                 'title'  => trans('gift::app.admin.datagrid.delete'),
                 'method' => 'DELETE',

+ 83 - 34
packages/Longyi/Gift/src/Http/Controllers/Admin/GiftController.php

@@ -5,7 +5,6 @@ namespace Longyi\Gift\Http\Controllers\Admin;
 use Illuminate\Http\JsonResponse;
 use Illuminate\View\View;
 use Longyi\Gift\Models\GiftCards;
-use Longyi\Gift\Models\GiftCardUsageLog;
 use Webkul\Admin\Http\Controllers\Controller;
 use Longyi\Gift\DataGrids\GiftCards\GiftCardsDataGrid;
 use Longyi\Gift\Repositories\GiftCardsRepository;
@@ -31,55 +30,105 @@ class GiftController extends Controller
     public function index(): View|JsonResponse
     {
         if (request()->ajax()) {
-             return datagrid(GiftCardsDataGrid::class)->process();
+            return datagrid(GiftCardsDataGrid::class)->process();
         }
 
         return view('gift::admin.index');
     }
 
-    /**
-     * Show the form for creating a new resource.
-     */
-    public function create(): View
-    {
-        return view('gift::admin.create');
-    }
-
     /**
      * Store a newly created resource in storage.
      */
-    public function store(): JsonResponse|\Illuminate\Http\RedirectResponse
+    public function store(): JsonResponse
     {
         $data = request()->validate([
-            'giftcard_amount' => 'required|numeric|min:0',
-            'customer_id' => 'required|integer',
-            'expirationdate' => 'required|date_format:Y-m-d',
-            'giftcard_status' => 'required|in:1,2',
+            'giftcard_amount'  => 'required|numeric|min:0',
+            'customer_email'   => 'nullable|email',
+            'customer_id'      => 'nullable|integer|exists:customers,id',
+            'expirationdate'   => 'required|date_format:Y-m-d H:i:s',
+            'giftcard_status'  => 'required|in:1,2',
         ]);
 
+        // 如果填写了邮箱,必须验证通过(customer_id 不能为空)
+        if (!empty($data['customer_email']) && empty($data['customer_id'])) {
+            return new JsonResponse([
+                'message' => '邮箱验证失败,该邮箱未注册',
+            ], 422);
+        }
+
         $data['giftcard_number'] = $this->generateUniqueGiftCardNumber();
         $data['used_giftcard_amount'] = 0;
         $data['type'] = 1;
         $data['remaining_giftcard_amount'] = $data['giftcard_amount'] ?? 0;
+        $data['channel'] = 'Administrator addition';
 
         $this->giftCardsRepository->create($data);
-        // 记录使用日志
-        GiftCardUsageLog::create([
-            'giftcard_number' => $data['giftcard_number'],
-            'customer_id' => $data['customer_id'],
-            'used_amount' => 0,
-            'balance_before' => $data['giftcard_amount'],
-            'balance_after' => $data['giftcard_amount'],
-            'action_type' => GiftCardUsageLog::ACTION_ADD,
-            'status' => 'completed',
-            'notes' => "Administrator addition",
+
+        return new JsonResponse([
+            'message' => trans('gift::app.admin.create.create-success'),
         ]);
-        if (request()->expectsJson()) {
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     */
+    public function edit(int $id): JsonResponse
+    {
+        $giftCard = $this->giftCardsRepository->find($id);
+
+        if (! $giftCard) {
             return new JsonResponse([
-                'message' => '礼品卡创建成功',
-            ]);
+                'message' => trans('gift::app.admin.edit.not-found'),
+            ], 404);
         }
-        return redirect()->route('admin.gift.index');
+
+        $data = $giftCard->toArray();
+
+        // 格式化过期时间,避免 toArray() 的时区序列化问题
+        if ($giftCard->expirationdate) {
+            $data['expirationdate'] = $giftCard->expirationdate->format('Y-m-d H:i:s');
+        }
+
+        // 附带 customer email 方便编辑
+        if ($giftCard->customer_id) {
+            $customer = \Webkul\Customer\Models\Customer::find($giftCard->customer_id);
+            if ($customer) {
+                $data['customer_email'] = $customer->email;
+            }
+        }
+
+        return new JsonResponse([
+            'data' => $data,
+        ]);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     */
+    public function update(): JsonResponse
+    {
+        $data = request()->validate([
+            'id'                => 'required|integer',
+            'expirationdate'    => 'required|date_format:Y-m-d H:i:s',
+            'giftcard_status'   => 'required|in:1,2',
+        ]);
+
+        $giftCard = $this->giftCardsRepository->find($data['id']);
+
+        if (! $giftCard) {
+            return new JsonResponse([
+                'message' => trans('gift::app.admin.edit.not-found'),
+            ], 404);
+        }
+
+        $giftCard->update([
+            'expirationdate'  => $data['expirationdate'],
+            'giftcard_status' => $data['giftcard_status'],
+        ]);
+
+        return new JsonResponse([
+            'message' => trans('gift::app.admin.edit.update-success'),
+        ]);
     }
 
     /**
@@ -143,11 +192,11 @@ class GiftController extends Controller
             $this->giftCardsRepository->delete($id);
 
             return new JsonResponse([
-                'message' => '礼品卡删除成功',
+                'message' => trans('gift::app.admin.datagrid.delete-success'),
             ]);
         } catch (\Exception $e) {
             return new JsonResponse([
-                'message' => '礼品卡删除失败',
+                'message' => trans('gift::app.admin.datagrid.delete-error'),
             ], 400);
         }
     }
@@ -161,7 +210,7 @@ class GiftController extends Controller
 
         if (empty($indices)) {
             return new JsonResponse([
-                'message' => '请选择要删除的礼品卡',
+                'message' => trans('gift::app.admin.datagrid.mass-delete-empty'),
             ], 422);
         }
 
@@ -171,11 +220,11 @@ class GiftController extends Controller
             }
 
             return new JsonResponse([
-                'message' => '选中的礼品卡删除成功',
+                'message' => trans('gift::app.admin.datagrid.delete-success'),
             ]);
         } catch (\Exception $e) {
             return new JsonResponse([
-                'message' => '批量删除失败',
+                'message' => trans('gift::app.admin.datagrid.mass-delete-error'),
             ], 400);
         }
     }

+ 44 - 61
packages/Longyi/Gift/src/Http/Controllers/Shop/GiftController.php

@@ -6,6 +6,7 @@ use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\JsonResource;
 use Illuminate\Http\Response;
+use Longyi\RewardPoints\Repositories\RewardPointRepository;
 use Webkul\Checkout\Facades\Cart;
 use Webkul\Shop\Http\Controllers\Controller;
 use Longyi\Gift\Repositories\GiftCardsRepository;
@@ -17,7 +18,8 @@ class GiftController extends Controller
 {
     public function __construct(
         protected GiftCardsRepository $giftCardRepository,
-        protected GiftCards $giftCardsModel
+        protected GiftCards $giftCardsModel,
+        protected RewardPointRepository $rewardPointRepository
     ) {
     }
 
@@ -29,11 +31,28 @@ class GiftController extends Controller
 
     public function lists(): JsonResponse
     {
+        $customerId = auth()->user()->id;
+        // 获取用户拥有的所有礼品卡
+        $userGiftCards = GiftCards::where('customer_id', $customerId)
+            ->where('giftcard_status', 1)
+            ->get()
+            ->keyBy('channel');
         $gifts = $this->giftCardsModel->giftcards();
-        return ApiResponse::success($gifts);
+        foreach ($gifts as &$gift) {
+            $channel = $gift['channel'];
+            if (isset($userGiftCards[$channel])) {
+                $gift['status'] = true;
+            } else {
+                $gift['status'] = false;
+            }
+        }
+        return ApiResponse::success([
+            'lists' => $gifts,
+            'myPoints' => $this->rewardPointRepository->getCustomerPoints($customerId)
+        ]);
     }
 
-    public function add($request): JsonResponse
+    public function add(Request $request): JsonResponse
     {
         $id = $request->input('id');
         if (!$id) {
@@ -60,10 +79,8 @@ class GiftController extends Controller
                 ->where('expirationdate', '>=', now())
                 ->orderBy('created_at', 'desc')
                 ->get();
-
-            return response()->json([
-                'success' => true,
-                'data' => $giftCards->map(function ($giftCard) {
+            return ApiResponse::success(
+                $giftCards->map(function ($giftCard) {
                     return [
                         'id' => $giftCard->id,
                         'giftcard_number' => $giftCard->giftcard_number,
@@ -72,16 +89,12 @@ class GiftController extends Controller
                         'formatted_remaining_giftcard_amount' => core()->formatPrice(core()->convertPrice($giftCard->remaining_giftcard_amount)),
                         'used_giftcard_amount' => $giftCard->used_giftcard_amount,
                         'expirationdate' => $giftCard->expirationdate->format('Y-m-d'),
-                        'giftcard_status' => $giftCard->giftcard_status,
+                        'giftcard_status' => $giftCard->giftcard_status
                     ];
-                }),
-            ]);
+                })
+            );
         } catch (\Exception $e) {
-            return response()->json([
-                'success' => false,
-                'message' => 'Failed to fetch gift cards',
-                'error' => $e->getMessage(),
-            ], Response::HTTP_INTERNAL_SERVER_ERROR);
+            return ApiResponse::error($e->getMessage());
         }
     }
     /**
@@ -98,62 +111,33 @@ class GiftController extends Controller
                ->where('customer_id', $customerId)->first();
            $cart = Cart::getCart();
            if (!$giftCard) {
-               return (new JsonResource([
-                   'data'     => new CustomCartResource (Cart::getCart()),
-                   'message'  => trans('Coupon not found.'),
-               ]))->response()->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
+               return ApiResponse::error('Coupon not found.');
            }
            $remainingGiftcardAmount = core()->convertPrice($giftCard->remaining_giftcard_amount);
            if ($remainingGiftcardAmount <= 0) {
-               return (new JsonResource([
-                   'data'     => new CustomCartResource(Cart::getCart()),
-                   'message'  => trans('Gift card already used.'),
-               ]))->response()->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
+               return ApiResponse::error('Gift card already used.');
            }
            if (!empty($cart->giftcard_amount)) {
-               return (new JsonResource([
-                   'data'     => new CustomCartResource(Cart::getCart()),
-                   'message'  => trans('The shopping cart has been paid with a gift card.'),
-               ]))->response()->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
+               return ApiResponse::error('The shopping cart has been paid with a gift card.');
            }
-
-
            $cartTotal = $cart->grand_total;
            $remainingAmount = $remainingGiftcardAmount - $cartTotal;
-
-           if ($remainingAmount >= 0) {
-               // Apply the gift card to the cart
-               GiftCards::setGiftCardCode($giftCard);
-               Cart::collectTotals();
-
-               return response()->json([
-                   'message' => 'Gift card applied successfully',
-                   'remaining_amount' => $remainingAmount,
-                   'giftcard_number' => $giftCard->giftcard_number,
-               ]);
-           } else {
-               // Apply the gift card to the cart
-               GiftCards::setGiftCardCode($giftCard);
-               Cart::collectTotals();
-               // Gift card amount becomes zero as it's fully used
-               return response()->json([
-                   'message' => 'Gift card applied successfully',
-                   'remaining_amount' => 0,
-                   'giftcard_number' => $giftCard->giftcard_number,
-               ]);
-           }
+           // Apply the gift card to the cart
+           GiftCards::setGiftCardCode($giftCard);
+           Cart::collectTotals();
+           // Gift card amount becomes zero as it's fully used
+           return ApiResponse::success([
+               'remaining_amount' => $remainingAmount >= 0 ? $remainingAmount : 0,
+               'giftcard_number' => $giftCard->giftcard_number,
+           ]);
        } catch (\Exception $e) {
-           return (new JsonResource([
-               'data'    => new CustomCartResource(Cart::getCart()),
-               'message' => trans('error'),
-               'error' => $e->getMessage()
-           ]))->response()->setStatusCode(Response::HTTP_INTERNAL_SERVER_ERROR);
+           return ApiResponse::error($e->getMessage());
        }
    }
     /**
      * Remove applied Giftcard from the cart.
      */
-    public function destroyGiftCard(): JsonResource
+    public function destroyGiftCard()
     {
         // Get the cart instance
         $cart = Cart::getCart();
@@ -175,10 +159,9 @@ class GiftController extends Controller
         }
 
         // Return response with updated cart data
-        return new JsonResource([
-            'data'     => new CustomCartResource(Cart::getCart()),
-            'message'  => trans('gift::app.giftcard.remove'),
-        ]);
+        return ApiResponse::success([
+                'data'     => new CustomCartResource(Cart::getCart())
+            ], trans('gift::app.giftcard.remove'));
     }
 
     /**

+ 11 - 7
packages/Longyi/Gift/src/Models/GiftCards.php

@@ -10,6 +10,8 @@ use Longyi\Gift\Contracts\GiftCards as GiftCardsContract;
 use Longyi\RewardPoints\Config\TransactionType;
 use Longyi\RewardPoints\Repositories\RewardPointRepository;
 use function Symfony\Component\Translation\t;
+use Illuminate\Support\Facades\DB;
+use Carbon\Carbon;
 
 class GiftCards extends Model implements GiftCardsContract
 {
@@ -33,7 +35,7 @@ class GiftCards extends Model implements GiftCardsContract
         'used_giftcard_amount' => 'decimal:2',
         'remaining_giftcard_amount' => 'decimal:2',
         'customer_id' => 'integer',
-        'expirationdate' => 'date',
+        'expirationdate' => 'datetime',
         'channel' => 'string',
     ];
     protected static function boot()
@@ -46,8 +48,10 @@ class GiftCards extends Model implements GiftCardsContract
                 'giftcard_number' => $giftCard->giftcard_number,
                 'customer_id' => $giftCard->customer_id,
                 'balance_before' => $giftCard->giftcard_amount,
+                'balance_after' => $giftCard->giftcard_amount,
                 'action_type' => GiftCardUsageLog::ACTION_ADD,
-                'notes' => $giftCard->notes
+                'status' => 'completed',
+                'notes' => $giftCard->channel ?: '',
             ]);
         });
     }
@@ -178,7 +182,7 @@ class GiftCards extends Model implements GiftCardsContract
                 'num' => 0,
                 'expirationdate' => 30,
                 'channel' => 'activity_26_04_21',
-                'notes' => '拉新活动添加'
+                'notes' => ''
             ],
             2 => [
                 'id' => 2,
@@ -188,7 +192,7 @@ class GiftCards extends Model implements GiftCardsContract
                 'num' => 0,
                 'expirationdate' => 30,
                 'channel' => 'activity_26_04_21',
-                'notes' => '拉新活动添加'
+                'notes' => ''
             ],
             3 => [
                 'id' => 3,
@@ -198,7 +202,7 @@ class GiftCards extends Model implements GiftCardsContract
                 'num' => 0,
                 'expirationdate' => 30,
                 'channel' => 'activity_26_04_21',
-                'notes' => '拉新活动添加'
+                'notes' => ''
             ],
             4 => [
                 'id' => 4,
@@ -208,7 +212,7 @@ class GiftCards extends Model implements GiftCardsContract
                 'num' => 0,
                 'expirationdate' => 30,
                 'channel' => 'activity_26_04_21',
-                'notes' => '拉新活动添加'
+                'notes' => ''
             ],
         ];
     }
@@ -252,7 +256,7 @@ class GiftCards extends Model implements GiftCardsContract
                 $customerId,
                 $points,
                 TransactionType::GIFT_CARD_REDEEM,
-                '积分兑换礼品卡'
+                'Gift Card Redeem'
             );
             DB::commit();
             return true;

+ 51 - 36
packages/Longyi/Gift/src/Resources/lang/en/app.php

@@ -2,51 +2,66 @@
 
 return [
     'admin' => [
-        'title' => '礼品卡管理',
+        'title' => 'Gift Cards',
+
+        'index' => [
+            'create-btn' => 'Add Gift Card',
+        ],
 
         'datagrid' => [
-            'id' => 'ID',
-            'giftcard-number' => '礼品卡号',
-            'giftcard-amount' => '卡金额',
-            'used-amount' => '已用金额',
-            'remaining-amount' => '剩余金额',
-            'customer-id' => '客户 ID',
-            'expiration-date' => '过期日期',
-            'status' => '状态',
-            'created-at' => '创建时间',
-            'edit' => '编辑',
-            'delete' => '删除',
-            'delete-success' => '选中的礼品卡删除成功',
+            'id'                => 'ID',
+            'giftcard-number'   => 'Card Number',
+            'giftcard-amount'   => 'Amount',
+            'used-amount'       => 'Used Amount',
+            'remaining-amount'  => 'Remaining Amount',
+            'customer-id'       => 'Customer ID',
+            'expiration-date'   => 'Expiration Date',
+            'status'            => 'Status',
+            'notes'             => 'Notes',
+            'created-at'        => 'Created At',
+            'edit'              => 'Edit',
+            'delete'            => 'Delete',
+            'delete-success'    => 'Deleted successfully',
+            'delete-error'      => 'Delete failed',
+            'mass-delete-empty' => 'Please select gift cards to delete',
+            'mass-delete-error' => 'Mass delete failed',
         ],
 
         'create' => [
-            'title' => '创建礼品卡',
-            'save-btn' => '保存',
-            'back-btn' => '返回',
-            'general' => '常规信息',
-            'giftcard-number' => '礼品卡号',
-            'giftcard-amount' => '卡金额',
-            'customer-id' => '客户 ID',
-            'expirationdate' => '过期日期',
-            'giftcard-status' => '状态',
-            'unused' => '未使用',
-            'used' => '已使用',
+            'title'                      => 'Create Gift Card',
+            'save-btn'                   => 'Save',
+            'back-btn'                   => 'Cancel',
+            'general'                    => 'General',
+            'giftcard-number'            => 'Card Number',
+            'giftcard-amount'            => 'Amount',
+            'customer-email'             => 'Customer Email',
+            'customer-email-placeholder' => 'Enter customer email',
+            'customer-id'                => 'Customer ID',
+            'expirationdate'             => 'Expiration Date',
+            'giftcard-status'            => 'Status',
+            'unused'                     => 'Unused',
+            'used'                       => 'Used',
+            'validating'                 => 'Validating...',
+            'validation-failed'          => 'Validation failed, please try again',
+            'create-success'             => 'Gift card created successfully',
         ],
 
         'edit' => [
-            'title' => '编辑礼品卡',
-            'save-btn' => '保存',
-            'back-btn' => '返回',
+            'title'          => 'Edit Gift Card',
+            'save-btn'       => 'Save',
+            'back-btn'       => 'Cancel',
+            'not-found'      => 'Gift card not found',
+            'update-success' => 'Gift card updated successfully',
         ],
     ],
     'giftcard' => [
-        'giftcard_amount' => 'Giftcard Amount',
-        'applied'   =>  'Giftcard Applied',
-        'discount'   =>  'Giftcard Discount',
-        'apply'   =>  'Apply Giftcard',
-        'enter-your-code' => 'Enter your code',
-        'remove'          => 'Remove Giftcard',
-        'remaining_giftcard_amount' => 'Remaining Amount',
-        'giftcard_number'      => 'Giftcard Number',
-    ]
+        'giftcard_amount'             => 'Gift Card Amount',
+        'applied'                     => 'Gift Card Applied',
+        'discount'                    => 'Gift Card Discount',
+        'apply'                       => 'Apply Gift Card',
+        'enter-your-code'             => 'Enter your code',
+        'remove'                      => 'Remove Gift Card',
+        'remaining_giftcard_amount'   => 'Remaining Amount',
+        'giftcard_number'             => 'Gift Card Number',
+    ],
 ];

+ 49 - 34
packages/Longyi/Gift/src/Resources/lang/zh_CN/app.php

@@ -4,49 +4,64 @@ return [
     'admin' => [
         'title' => '礼品卡管理',
 
+        'index' => [
+            'create-btn' => '添加礼品卡',
+        ],
+
         'datagrid' => [
-            'id' => 'ID',
-            'giftcard-number' => '礼品卡号',
-            'giftcard-amount' => '卡金额',
-            'used-amount' => '已用金额',
-            'remaining-amount' => '剩余金额',
-            'customer-id' => '客户 ID',
-            'expiration-date' => '过期日期',
-            'status' => '状态',
-            'created-at' => '创建时间',
-            'edit' => '编辑',
-            'delete' => '删除',
-            'delete-success' => '选中的礼品卡删除成功',
+            'id'                => 'ID',
+            'giftcard-number'   => '礼品卡号',
+            'giftcard-amount'   => '卡金额',
+            'used-amount'       => '已用金额',
+            'remaining-amount'  => '剩余金额',
+            'customer-id'       => '客户 ID',
+            'expiration-date'   => '过期日期',
+            'status'            => '状态',
+            'notes'             => '备注',
+            'created-at'        => '创建时间',
+            'edit'              => '编辑',
+            'delete'            => '删除',
+            'delete-success'    => '删除成功',
+            'delete-error'      => '删除失败',
+            'mass-delete-empty' => '请选择要删除的礼品卡',
+            'mass-delete-error' => '批量删除失败',
         ],
 
         'create' => [
-            'title' => '创建礼品卡',
-            'save-btn' => '保存',
-            'back-btn' => '返回',
-            'general' => '常规信息',
-            'giftcard-number' => '礼品卡号',
-            'giftcard-amount' => '卡金额',
-            'customer-id' => '客户 ID',
-            'expirationdate' => '过期日期',
-            'giftcard-status' => '状态',
-            'unused' => '未使用',
-            'used' => '已使用',
+            'title'                      => '创建礼品卡',
+            'save-btn'                   => '保存',
+            'back-btn'                   => '返回',
+            'general'                    => '常规信息',
+            'giftcard-number'            => '礼品卡号',
+            'giftcard-amount'            => '卡金额',
+            'customer-email'             => '用户邮箱',
+            'customer-email-placeholder' => '请输入用户邮箱',
+            'customer-id'                => '客户 ID',
+            'expirationdate'             => '过期日期',
+            'giftcard-status'            => '状态',
+            'unused'                     => '未使用',
+            'used'                       => '已使用',
+            'validating'                 => '正在验证...',
+            'validation-failed'          => '验证失败,请稍后重试',
+            'create-success'             => '礼品卡创建成功',
         ],
 
         'edit' => [
-            'title' => '编辑礼品卡',
-            'save-btn' => '保存',
-            'back-btn' => '返回',
+            'title'          => '编辑礼品卡',
+            'save-btn'       => '保存',
+            'back-btn'       => '返回',
+            'not-found'      => '礼品卡未找到',
+            'update-success' => '礼品卡更新成功',
         ],
     ],
     'giftcard' => [
-        'giftcard_amount' => 'Giftcard Amount',
-        'applied'   =>  'Giftcard Applied',
-        'discount'   =>  'Giftcard Discount',
-        'apply'   =>  'Apply Giftcard',
-        'enter-your-code' => 'Enter your code',
-        'remove'          => 'Remove Giftcard',
-        'remaining_giftcard_amount' => 'Remaining Amount',
-        'giftcard_number'      => 'Giftcard Number',
+        'giftcard_amount'             => '礼品卡金额',
+        'applied'                     => '礼品卡已应用',
+        'discount'                    => '礼品卡折扣',
+        'apply'                       => '应用礼品卡',
+        'enter-your-code'             => '请输入卡号',
+        'remove'                      => '移除礼品卡',
+        'remaining_giftcard_amount'   => '剩余金额',
+        'giftcard_number'             => '礼品卡号',
     ],
 ];

+ 0 - 230
packages/Longyi/Gift/src/Resources/views/admin/create.blade.php

@@ -1,230 +0,0 @@
-
-<x-admin::layouts>
-    <x-slot:title>
-        创建礼品卡
-    </x-slot>
-
-    <div class="flex justify-between items-center">
-        <p class="text-xl text-gray-800 font-bold">
-            创建礼品卡
-        </p>
-
-        <div class="flex gap-x-2.5">
-            <a
-                href="{{ route('admin.gift.index') }}"
-                class="px-4 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-100"
-            >
-                返回
-            </a>
-
-            <button
-                type="submit"
-                form="gift-form"
-                class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
-            >
-                保存
-            </button>
-        </div>
-    </div>
-
-    <x-admin::form
-        :action="route('admin.gift.store')"
-        id="gift-form"
-    >
-        {{-- CSRF Token --}}
-        @csrf
-
-        <div class="flex flex-col gap-1.5 mt-2.5">
-            {{-- General Section --}}
-            <x-admin::accordion>
-                <x-slot:header>
-                    <p class="p-2.5 text-gray-800 text-base font-semibold">
-                        常规信息
-                    </p>
-                </x-slot>
-
-                <x-slot:content>
-                    <x-admin::form.control-group>
-                        <x-admin::form.control-group.label>
-                            卡金额
-                        </x-admin::form.control-group.label>
-
-                        <x-admin::form.control-group.control
-                            type="text"
-                            name="giftcard_amount"
-                            :value="old('giftcard_amount')"
-                            rules="numeric|min:0"
-                            label="卡金额"
-                            placeholder="0.00"
-                        />
-
-                        <x-admin::form.control-group.error
-                            control-name="giftcard_amount"
-                        />
-                    </x-admin::form.control-group>
-
-                    <x-admin::form.control-group>
-                        <x-admin::form.control-group.label>
-                            用户邮箱
-                        </x-admin::form.control-group.label>
-
-                        <x-admin::form.control-group.control
-                            type="text"
-                            name="customer_email_display"
-                            :value="old('customer_email_display')"
-                            rules="email"
-                            label="用户邮箱"
-                            placeholder="请输入用户邮箱"
-                            id="customer_email_create"
-                            class="customer-email-input"
-                        />
-
-                        <input type="hidden" name="customer_id" id="customer_id_create" value="{{ old('customer_id') }}" />
-
-                        <div id="email-validation-message-create" class="mt-1 text-sm"></div>
-
-                        <x-admin::form.control-group.error
-                            control-name="customer_email_display"
-                        />
-                    </x-admin::form.control-group>
-
-                    <x-admin::form.control-group>
-                        <x-admin::form.control-group.label class="required">
-                            过期日期
-                        </x-admin::form.control-group.label>
-
-                        <x-admin::form.control-group.control
-                            type="date"
-                            name="expirationdate"
-                            :value="old('expirationdate')"
-                            rules="required"
-                            label="过期日期"
-                        />
-
-                        <x-admin::form.control-group.error
-                            control-name="expirationdate"
-                        />
-                    </x-admin::form.control-group>
-
-                    <x-admin::form.control-group>
-                        <x-admin::form.control-group.label>
-                            状态
-                        </x-admin::form.control-group.label>
-
-                        <x-admin::form.control-group.control
-                            type="select"
-                            name="giftcard_status"
-                            :value="old('giftcard_status', 1)"
-                            label="状态"
-                        >
-                            <option value="1">未使用</option>
-                            <option value="2">已使用</option>
-                        </x-admin::form.control-group.control>
-
-                        <x-admin::form.control-group.error
-                            control-name="giftcard_status"
-                        />
-                    </x-admin::form.control-group>
-                </x-slot>
-            </x-admin::accordion>
-        </div>
-    </x-admin::form>
-
-    <script>
-        document.addEventListener('DOMContentLoaded', function() {
-            // 等待一小段时间确保所有组件都渲染完成
-            setTimeout(() => {
-                // 尝试多种方式获取邮箱输入框
-                const emailInput = document.getElementById('customer_email_create') ||
-                    document.querySelector('input[name="customer_email_display"]') ||
-                    document.querySelector('.customer-email-input') ||
-                    document.querySelector('input[placeholder="请输入用户邮箱"]');
-                const customerIdInput = document.getElementById('customer_id_create');
-                const validationMessage = document.getElementById('email-validation-message-create');
-                let debounceTimer;
-
-                console.log('=== Debug Info ===');
-                console.log('Email input:', emailInput);
-                console.log('Customer ID input:', customerIdInput);
-                console.log('Validation message:', validationMessage);
-
-                // 打印所有 input 元素,帮助调试
-                const allInputs = document.querySelectorAll('input');
-                console.log('All inputs:', Array.from(allInputs).map(input => ({
-                    name: input.name,
-                    id: input.id,
-                    class: input.className,
-                    placeholder: input.placeholder
-                })));
-
-                if (emailInput) {
-                    console.log('✓ Email input found:', emailInput);
-
-                    emailInput.addEventListener('blur', function() {
-                        console.log('Blur event triggered, value:', this.value);
-                        validateEmail(this.value.trim());
-                    });
-                } else {
-                    console.error('✗ Email input element not found!');
-                }
-
-                function validateEmail(email) {
-                    clearTimeout(debounceTimer);
-
-                    if (!email) {
-                        if (validationMessage) validationMessage.innerHTML = '';
-                        if (customerIdInput) customerIdInput.value = '';
-                        return;
-                    }
-
-                    debounceTimer = setTimeout(() => {
-                        console.log('Validating email:', email);
-                        if (validationMessage) {
-                            validationMessage.innerHTML = '<span class="text-gray-500">正在验证...</span>';
-                        }
-
-                        fetch("{{ route('admin.gift.validate_email') }}", {
-                            method: 'POST',
-                            headers: {
-                                'Content-Type': 'application/json',
-                                'X-CSRF-TOKEN': '{{ csrf_token() }}'
-                            },
-                            body: JSON.stringify({ email: email })
-                        })
-                            .then(response => {
-                                console.log('Response status:', response.status);
-                                return response.json();
-                            })
-                            .then(data => {
-                                console.log('Response data:', data);
-                                if (data.valid) {
-                                    if (validationMessage) {
-                                        validationMessage.innerHTML = `<span class="text-green-600">✓ ${data.message} (${data.customer_name})</span>`;
-                                    }
-                                    if (customerIdInput) {
-                                        customerIdInput.value = data.customer_id || '';
-                                    }
-                                } else {
-                                    if (validationMessage) {
-                                        validationMessage.innerHTML = `<span class="text-red-600">✗ ${data.message}</span>`;
-                                    }
-                                    if (customerIdInput) {
-                                        customerIdInput.value = '';
-                                    }
-                                }
-                            })
-                            .catch(error => {
-                                console.error('Validation error:', error);
-                                if (validationMessage) {
-                                    validationMessage.innerHTML = '<span class="text-red-600">✗ 验证失败,请稍后重试</span>';
-                                }
-                                if (customerIdInput) {
-                                    customerIdInput.value = '';
-                                }
-                            });
-                    }, 500);
-                }
-            }, 1000); // 延迟1秒执行,确保组件渲染完成
-        });
-    </script>
-</x-admin::layouts>

+ 414 - 20
packages/Longyi/Gift/src/Resources/views/admin/index.blade.php

@@ -1,25 +1,419 @@
 <x-admin::layouts>
     <x-slot:title>
-        礼品卡管理
+        @lang('gift::app.admin.title')
     </x-slot>
 
-    @if(session('success'))
-        <div class="mb-4 px-4 py-3 bg-green-500 text-white rounded-lg">
-            {{ session('success') }}
-        </div>
-    @endif
-
-    <div class="flex justify-between items-center">
-        <p class="text-xl text-gray-800 font-bold">
-            礼品卡列表
-        </p>
-
-        <a href="{{ route('admin.gift.create') }}">
-            <button class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
-                添加礼品卡
-            </button>
-        </a>
-    </div>
-
-    <x-admin::datagrid src="{{ route('admin.gift.index') }}"></x-admin::datagrid>
+    <v-gift-cards>
+        <!-- DataGrid Shimmer -->
+        <x-admin::shimmer.datagrid />
+    </v-gift-cards>
+
+    @pushOnce('scripts')
+        <script
+            type="text/x-template"
+            id="v-gift-cards-template"
+        >
+            <div>
+                <div class="flex items-center justify-between">
+                    <p class="text-xl font-bold text-gray-800 dark:text-white">
+                        @lang('gift::app.admin.title')
+                    </p>
+
+                    <div class="flex items-center gap-x-2.5">
+                        @if (bouncer()->hasPermission('gift.create'))
+                            <button
+                                type="button"
+                                class="primary-button"
+                                @click="selectedGift=0; resetForm(); $refs.giftUpdateOrCreateModal.toggle()"
+                            >
+                                @lang('gift::app.admin.index.create-btn')
+                            </button>
+                        @endif
+                    </div>
+                </div>
+
+                <x-admin::datagrid
+                    :src="route('admin.gift.index')"
+                    ref="datagrid"
+                >
+                    <template #body="{
+                        isLoading,
+                        available,
+                        applied,
+                        selectAll,
+                        sort,
+                        performAction
+                    }">
+                        <template v-if="isLoading">
+                            <x-admin::shimmer.datagrid.table.body />
+                        </template>
+
+                        <template v-else>
+                            <div
+                                v-for="record in available.records"
+                                class="row grid items-center gap-2.5 border-b px-4 py-4 text-gray-600 transition-all hover:bg-gray-50 dark:border-gray-800 dark:text-gray-300 dark:hover:bg-gray-950"
+                                :style="`grid-template-columns: repeat(${gridsCount}, minmax(0, 1fr))`"
+                            >
+                                <!-- Mass Actions Checkbox -->
+                                <p v-if="available.massActions.length">
+                                    <label :for="`mass_action_select_record_${record[available.meta.primary_column]}`">
+                                        <input
+                                            type="checkbox"
+                                            :name="`mass_action_select_record_${record[available.meta.primary_column]}`"
+                                            :value="record[available.meta.primary_column]"
+                                            :id="`mass_action_select_record_${record[available.meta.primary_column]}`"
+                                            class="peer hidden"
+                                            v-model="applied.massActions.indices"
+                                        >
+                                        <span class="icon-uncheckbox peer-checked:icon-checked cursor-pointer rounded-md text-2xl peer-checked:text-blue-600"></span>
+                                    </label>
+                                </p>
+
+                                <!-- ID -->
+                                <p>@{{ record.id }}</p>
+
+                                <!-- Card Amount -->
+                                <p>@{{ record.giftcard_amount }}</p>
+
+                                <!-- Used Amount -->
+                                <p>@{{ record.used_giftcard_amount }}</p>
+
+                                <!-- Remaining Amount -->
+                                <p>@{{ record.remaining_giftcard_amount }}</p>
+
+                                <!-- Customer Email -->
+                                <p v-html="record.customer_email"></p>
+
+                                <!-- Expiration Date -->
+                                <p>@{{ record.expirationdate }}</p>
+
+                                <!-- Status -->
+                                <p v-html="record.giftcard_status"></p>
+
+                                <!-- Notes -->
+                                <p>@{{ record.notes }}</p>
+
+                                <!-- Created At -->
+                                <p>@{{ record.created_at }}</p>
+
+                                <!-- Actions -->
+                                <div class="flex justify-end">
+                                    @if (bouncer()->hasPermission('gift.edit'))
+                                        <a @click="selectedGift=1; editModal(record.actions.find(action => action.index === 'edit')?.url)">
+                                            <span
+                                                :class="record.actions.find(action => action.index === 'edit')?.icon"
+                                                class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center"
+                                            >
+                                            </span>
+                                        </a>
+                                    @endif
+
+                                    @if (bouncer()->hasPermission('gift.delete'))
+                                        <a @click="performAction(record.actions.find(action => action.index === 'delete'))">
+                                            <span
+                                                :class="record.actions.find(action => action.index === 'delete')?.icon"
+                                                class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center"
+                                            >
+                                            </span>
+                                        </a>
+                                    @endif
+                                </div>
+                            </div>
+                        </template>
+                    </template>
+                </x-admin::datagrid>
+
+                <!-- Create / Edit Modal -->
+                <x-admin::form
+                    v-slot="{ meta, errors, handleSubmit }"
+                    as="div"
+                    ref="modalForm"
+                >
+                    <form
+                        @submit="handleSubmit($event, updateOrCreate)"
+                        ref="giftCreateForm"
+                    >
+                        <x-admin::modal ref="giftUpdateOrCreateModal">
+                            <x-slot:header>
+                                <p class="text-lg font-bold text-gray-800 dark:text-white">
+                                    <span v-if="selectedGift">
+                                        @lang('gift::app.admin.edit.title')
+                                    </span>
+                                    <span v-else>
+                                        @lang('gift::app.admin.create.title')
+                                    </span>
+                                </p>
+                            </x-slot>
+
+                            <x-slot:content>
+                                <x-admin::form.control-group.control
+                                    type="hidden"
+                                    name="id"
+                                    v-model="selectedGiftData.id"
+                                />
+
+                                <!-- 编辑模式:显示只读信息 + 可修改的过期时间和状态 -->
+                                <template v-if="selectedGift">
+                                    <!-- Gift Card Number (只读) -->
+                                    <div class="mb-4 rounded-md bg-gray-50 p-4 dark:bg-gray-800">
+                                        <div class="grid grid-cols-2 gap-4 text-sm">
+                                            <div>
+                                                <span class="text-gray-500">@lang('gift::app.admin.datagrid.giftcard-number')</span>
+                                                <p class="font-medium text-gray-800 dark:text-white">@{{ selectedGiftData.giftcard_number }}</p>
+                                            </div>
+                                            <div>
+                                                <span class="text-gray-500">@lang('gift::app.admin.create.giftcard-amount')</span>
+                                                <p class="font-medium text-gray-800 dark:text-white">@{{ selectedGiftData.giftcard_amount }}</p>
+                                            </div>
+                                            <div>
+                                                <span class="text-gray-500">@lang('gift::app.admin.datagrid.used-amount')</span>
+                                                <p class="font-medium text-gray-800 dark:text-white">@{{ selectedGiftData.used_giftcard_amount }}</p>
+                                            </div>
+                                            <div>
+                                                <span class="text-gray-500">@lang('gift::app.admin.datagrid.remaining-amount')</span>
+                                                <p class="font-medium text-gray-800 dark:text-white">@{{ selectedGiftData.remaining_giftcard_amount }}</p>
+                                            </div>
+                                            <div>
+                                                <span class="text-gray-500">用户邮箱</span>
+                                                <p class="font-medium text-gray-800 dark:text-white">@{{ selectedGiftData.customer_email || '未绑定' }}</p>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </template>
+
+                                <!-- 创建模式:显示金额和邮箱 -->
+                                <template v-else>
+                                    <!-- Card Amount -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label class="required">
+                                            @lang('gift::app.admin.create.giftcard-amount')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="text"
+                                            name="giftcard_amount"
+                                            rules="required|numeric|min:0"
+                                            v-model="selectedGiftData.giftcard_amount"
+                                            :label="trans('gift::app.admin.create.giftcard-amount')"
+                                            placeholder="0.00"
+                                        />
+                                        <x-admin::form.control-group.error control-name="giftcard_amount" />
+                                    </x-admin::form.control-group>
+
+                                    <!-- Customer Email -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label>
+                                            @lang('gift::app.admin.create.customer-email')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="text"
+                                            name="customer_email"
+                                            v-model="selectedGiftData.customer_email"
+                                            :label="trans('gift::app.admin.create.customer-email')"
+                                            placeholder="@lang('gift::app.admin.create.customer-email-placeholder')"
+                                        />
+                                        <input type="hidden" name="customer_id" v-model="selectedGiftData.customer_id" />
+                                        <div v-if="emailValidationMessage" v-html="emailValidationMessage" class="mt-1 text-sm"></div>
+                                        <x-admin::form.control-group.error control-name="customer_email" />
+                                        <x-admin::form.control-group.error control-name="customer_id" />
+                                    </x-admin::form.control-group>
+                                </template>
+
+                                <!-- Expiration Date (创建和编辑都显示) -->
+                                <x-admin::form.control-group>
+                                    <x-admin::form.control-group.label class="required">
+                                        @lang('gift::app.admin.create.expirationdate')
+                                    </x-admin::form.control-group.label>
+                                    <x-admin::form.control-group.control
+                                        type="datetime"
+                                        name="expirationdate"
+                                        rules="required"
+                                        v-model="selectedGiftData.expirationdate"
+                                        :label="trans('gift::app.admin.create.expirationdate')"
+                                    />
+                                    <x-admin::form.control-group.error control-name="expirationdate" />
+                                </x-admin::form.control-group>
+
+                                <!-- Status (创建和编辑都显示) -->
+                                <x-admin::form.control-group>
+                                    <x-admin::form.control-group.label>
+                                        @lang('gift::app.admin.create.giftcard-status')
+                                    </x-admin::form.control-group.label>
+                                    <x-admin::form.control-group.control
+                                        type="select"
+                                        name="giftcard_status"
+                                        v-model="selectedGiftData.giftcard_status"
+                                    >
+                                        <option value="1">@lang('gift::app.admin.create.unused')</option>
+                                        <option value="2">@lang('gift::app.admin.create.used')</option>
+                                    </x-admin::form.control-group.control>
+                                    <x-admin::form.control-group.error control-name="giftcard_status" />
+                                </x-admin::form.control-group>
+                            </x-slot>
+
+                            <x-slot:footer>
+                                <x-admin::button
+                                    button-type="button"
+                                    class="secondary-button mr-2"
+                                    :title="trans('gift::app.admin.create.back-btn')"
+                                    @click="$refs.giftUpdateOrCreateModal.close()"
+                                />
+                                <x-admin::button
+                                    button-type="submit"
+                                    class="primary-button"
+                                    :title="trans('gift::app.admin.create.save-btn')"
+                                    ::loading="isLoading"
+                                    ::disabled="isLoading"
+                                />
+                            </x-slot>
+                        </x-admin::modal>
+                    </form>
+                </x-admin::form>
+            </div>
+        </script>
+
+        <script type="module">
+            app.component('v-gift-cards', {
+                template: '#v-gift-cards-template',
+
+                data() {
+                    return {
+                        selectedGift: 0,
+                        selectedGiftData: {},
+                        isLoading: false,
+                        emailValidationMessage: '',
+                        emailDebounceTimer: null,
+                    }
+                },
+
+                watch: {
+                    'selectedGiftData.customer_email': {
+                        handler(newEmail) {
+                            // 清除上一次的定时器
+                            if (this.emailDebounceTimer) {
+                                clearTimeout(this.emailDebounceTimer);
+                            }
+                            // 编辑模式不触发验证
+                            if (this.selectedGift) {
+                                return;
+                            }
+                            // 500ms 防抖
+                            this.emailDebounceTimer = setTimeout(() => {
+                                this.validateEmail(newEmail);
+                            }, 500);
+                        },
+                        immediate: false
+                    }
+                },
+
+                computed: {
+                    gridsCount() {
+                        let count = this.$refs.datagrid.available.columns.length;
+                        if (this.$refs.datagrid.available.actions.length) {
+                            ++count;
+                        }
+                        if (this.$refs.datagrid.available.massActions.length) {
+                            ++count;
+                        }
+                        return count;
+                    },
+                },
+
+                methods: {
+                    updateOrCreate(params, { resetForm, setErrors }) {
+                        // 创建模式:检查邮箱是否已填写且验证通过
+                        if (!params.id && this.selectedGiftData.customer_email && !this.selectedGiftData.customer_id) {
+                            this.$emitter.emit('add-flash', { type: 'error', message: '请等待邮箱验证通过后再提交' });
+                            return;
+                        }
+
+                        this.isLoading = true;
+
+                        let formData = new FormData(this.$refs.giftCreateForm);
+
+                        if (params.id) {
+                            formData.append('_method', 'put');
+                        }
+
+                        this.$axios.post(
+                            params.id
+                                ? "{{ route('admin.gift.update') }}"
+                                : "{{ route('admin.gift.store') }}",
+                            formData
+                        )
+                            .then((response) => {
+                                this.isLoading = false;
+                                this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
+                                this.$refs.giftUpdateOrCreateModal.close();
+                                this.$refs.datagrid.get();
+                                resetForm();
+                                this.emailValidationMessage = '';
+                            })
+                            .catch(error => {
+                                this.isLoading = false;
+                                if (error.response.status == 422) {
+                                    setErrors(error.response.data.errors);
+                                }
+                            });
+                    },
+
+                    editModal(url) {
+                        this.$axios.get(url)
+                            .then((response) => {
+                                this.selectedGiftData = response.data.data;
+
+                                // 类型转换:确保 select 能正确匹配
+                                if (this.selectedGiftData.giftcard_status !== undefined) {
+                                    this.selectedGiftData.giftcard_status = String(this.selectedGiftData.giftcard_status);
+                                }
+
+                                // 格式化过期时间为 flat-picker 需要的格式 Y-m-d H:i:s
+                                if (this.selectedGiftData.expirationdate) {
+                                    this.selectedGiftData.expirationdate = this.selectedGiftData.expirationdate.replace('T', ' ').replace(/\.\d+Z$/, '');
+                                }
+
+                                this.emailValidationMessage = '';
+                                this.$refs.giftUpdateOrCreateModal.toggle();
+                            })
+                            .catch(error => this.$emitter.emit('add-flash', {
+                                type: 'error', message: error.response.data.message
+                            }));
+                    },
+
+                    validateEmail(email) {
+                        this.emailValidationMessage = '';
+
+                        if (!email) {
+                            this.selectedGiftData.customer_id = null;
+                            return;
+                        }
+
+                        this.emailValidationMessage = '<span class="text-gray-500">@lang("gift::app.admin.create.validating")</span>';
+
+                        this.$axios.post(
+                            "{{ route('admin.gift.validate_email') }}",
+                            { email: email }
+                        )
+                            .then((response) => {
+                                if (response.data.valid) {
+                                    this.emailValidationMessage = '<span class="text-green-600">' + response.data.message + ' (' + response.data.customer_name + ')</span>';
+                                    this.selectedGiftData.customer_id = response.data.customer_id;
+                                } else {
+                                    this.emailValidationMessage = '<span class="text-red-600">' + response.data.message + '</span>';
+                                    this.selectedGiftData.customer_id = null;
+                                }
+                            })
+                            .catch(error => {
+                                this.emailValidationMessage = '<span class="text-red-600">@lang("gift::app.admin.create.validation-failed")</span>';
+                                this.selectedGiftData.customer_id = null;
+                            });
+                    },
+
+                    resetForm() {
+                        this.selectedGiftData = {};
+                        this.emailValidationMessage = '';
+                    }
+                }
+            })
+        </script>
+    @endPushOnce
 </x-admin::layouts>

+ 5 - 4
packages/Longyi/Gift/src/Routes/admin-routes.php

@@ -6,13 +6,14 @@ use Longyi\Gift\Http\Controllers\Admin\GiftController;
 Route::group(['middleware' => ['web', 'admin'], 'prefix' => 'admin/gift'], function () {
     Route::controller(GiftController::class)->group(function () {
         Route::get('', 'index')->name('admin.gift.index');
-        // Create routes
-        Route::get('/create', 'create')->name('admin.gift.create');
+        // Create & Update
         Route::post('/store', 'store')->name('admin.gift.store');
-        // Delete routes
+        Route::get('/edit/{id}', 'edit')->name('admin.gift.edit');
+        Route::put('/update', 'update')->name('admin.gift.update');
+        // Delete
         Route::delete('/{id}', 'destroy')->name('admin.gift.destroy');
         Route::post('/mass-delete', 'massDelete')->name('admin.gift.mass_delete');
-        //validate
+        // Validate
         Route::post('/validate-email', 'validateEmail')->name('admin.gift.validate_email');
     });
 });

+ 20 - 0
packages/Longyi/Pay/Afterpay/src/Config/payment-methods.php

@@ -0,0 +1,20 @@
+<?php
+
+return [
+    'afterpay'  => [
+        'code'        => 'afterpay',
+        'title'       => 'Afterpay',
+        'description' => 'Afterpay',
+        'class'       => 'Longyi\Pay\Afterpay\Payment\Afterpay',
+        'active'      => true,
+        'sort'        => 1,
+    ],
+    'clearpay'  => [
+        'code'        => 'clearpay',
+        'title'       => 'Clearpay',
+        'description' => 'Clearpay',
+        'class'       => 'Longyi\Pay\Afterpay\Payment\Clearpay',
+        'active'      => true,
+        'sort'        => 1,
+    ],
+];

+ 195 - 0
packages/Longyi/Pay/Afterpay/src/Config/system.php

@@ -0,0 +1,195 @@
+<?php
+
+return [
+    [
+        'key'    => 'sales.payment_methods.afterpay',
+        'name'   => 'Afterpay',
+        'info'   => 'Information about Afterpay',
+        'sort'   => 5,
+        'fields' => [
+            [
+                'name'          => 'title',
+                'title'         => 'Title',
+                'type'          => 'text',
+                'validation'    => 'required',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ], [
+                'name'          => 'description',
+                'title'         => 'Description',
+                'type'          => 'textarea',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ], [
+                'name' => 'mode',
+                'title' => 'Mode',
+                'type' => 'select',
+                'options' => [
+                    [
+                        'title' => 'live',
+                        'value' => 'live',
+                    ], [
+                        'title' => 'test',
+                        'value' => 'test',
+                    ],
+                ],
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'client_id',
+                'title' => 'Merchant ID',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'secret_id',
+                'title' => 'Merchant Key',
+                'type' => 'password',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name'          => 'sort',
+                'title'         => 'Sort Order',
+                'type'          => 'text',
+                'validation'    => 'numeric',
+                'channel_based' => true,
+                'locale_based'  => false,
+                'default'       => 5,
+            ], [
+                'name'          => 'min_order_total',
+                'title'         => 'Minimum Order Total',
+                'type'          => 'blade',
+                'path'          => 'Afterpay::admin.system.readonly-value',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ], [
+                'name'          => 'max_order_total',
+                'title'         => 'Maximum Order Total',
+                'type'          => 'blade',
+                'path'          => 'Afterpay::admin.system.readonly-value',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ], [
+                'name'          => 'update_limits',
+                'title'         => 'Update Limits',
+                'type'          => 'blade',
+                'path'          => 'Afterpay::admin.system.update-limits',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ], [
+                'name'          => 'active',
+                'title'         => 'Status',
+                'type'          => 'boolean',
+                'validation'    => 'required',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ]
+        ]
+    ],
+    [
+        'key'    => 'sales.payment_methods.clearpay',
+        'name'   => 'Clearpay',
+        'info'   => 'Information about Clearpay',
+        'sort'   => 5,
+        'fields' => [
+            [
+                'name'          => 'title',
+                'title'         => 'Title',
+                'type'          => 'text',
+                'validation'    => 'required',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ], [
+                'name'          => 'description',
+                'title'         => 'Description',
+                'type'          => 'textarea',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ], [
+                'name' => 'mode',
+                'title' => 'Mode',
+                'type' => 'select',
+                'options' => [
+                    [
+                        'title' => 'live',
+                        'value' => 'live',
+                    ], [
+                        'title' => 'test',
+                        'value' => 'test',
+                    ],
+                ],
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'client_id',
+                'title' => 'Merchant ID',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'secret_id',
+                'title' => 'Merchant Key',
+                'type' => 'password',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name'          => 'sort',
+                'title'         => 'Sort Order',
+                'type'          => 'text',
+                'validation'    => 'numeric',
+                'channel_based' => true,
+                'locale_based'  => false,
+                'default'       => 5,
+            ],
+//            [
+//                'name'          => 'allowed_countries',
+//                'title'         => 'Allowed Countries',
+//                'type'          => 'multiselect',
+//                'options'       => 'Longyi\Pay\Afterpay\Payment\Clearpay@getCountryOptions',
+//                'channel_based' => false,
+//                'locale_based'  => false,
+//            ],
+            [
+                'name'          => 'min_order_total',
+                'title'         => 'Minimum Order Total',
+                'type'          => 'blade',
+                'path'          => 'Afterpay::admin.system.readonly-value',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ], [
+                'name'          => 'max_order_total',
+                'title'         => 'Maximum Order Total',
+                'type'          => 'blade',
+                'path'          => 'Afterpay::admin.system.readonly-value',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ], [
+                'name'          => 'update_limits',
+                'title'         => 'Update Limits',
+                'type'          => 'blade',
+                'path'          => 'Afterpay::admin.system.update-limits',
+                'channel_based' => false,
+                'locale_based'  => false,
+            ], [
+                'name'          => 'active',
+                'title'         => 'Status',
+                'type'          => 'boolean',
+                'validation'    => 'required',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ]
+        ]
+    ]
+];

+ 89 - 0
packages/Longyi/Pay/Afterpay/src/Http/Controllers/AfterpayController.php

@@ -0,0 +1,89 @@
+<?php
+
+namespace Longyi\Pay\Afterpay\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Artisan;
+use Longyi\Pay\Afterpay\Payment\Afterpay;
+use Longyi\Pay\Afterpay\Payment\Clearpay;
+use Webkul\Sales\Repositories\OrderRepository;
+
+class AfterpayController extends Controller
+{
+    public function __construct(
+        protected OrderRepository $orderRepository,
+        protected Afterpay $afterpay,
+        protected Clearpay $clearpay,
+    ) {}
+    public function return(Request $request)
+    {
+        $orderId = $request->input('order_id');
+        $status = $request->input('status');
+        $orderToken = $request->input('orderToken');
+        try {
+            switch ($status) {
+                case 'SUCCESS':
+                    $this->afterpay->paymentsSuccess($orderId, $orderToken);
+                    return redirect()->route('shop.checkout.onepage.success');
+                    break;
+                case 'CANCELLED':
+                    //跳转到订单列表
+                    return redirect()->route('shop.checkout.cart.index');
+                    break;
+                default:
+                    break;
+            }
+        } catch (\Exception $e) {
+           //记录日志
+            \Log::error($e->getMessage() . ' orderId=' . $orderId);
+        }
+    }
+
+    public function updateLimits(Request $request)
+    {
+        $code = $request->input('code', 'afterpay');
+
+        try {
+            $result = $this->$code->fetchConfiguration($code);
+
+            if (! $result) {
+                return response()->json([
+                    'success' => false,
+                    'message' => 'Failed to fetch configuration from Afterpay',
+                ]);
+            }
+
+            // 写入数据库
+            $prefix = 'sales.payment_methods.' . $code . '.';
+            $fields = [
+                'min_order_total' => $result['min_order_total'] ?? '',
+                'max_order_total' => $result['max_order_total'] ?? '',
+            ];
+
+            foreach ($fields as $field => $value) {
+                \Webkul\Core\Models\CoreConfig::updateOrCreate(
+                    ['code' => $prefix . $field],
+                    ['value' => $value]
+                );
+            }
+
+            // 清除配置缓存
+            Artisan::call('config:clear');
+            Artisan::call('cache:clear');
+
+            return response()->json([
+                'success'         => true,
+                'message'         => 'Limits updated successfully',
+                'min_order_total' => $result['min_order_total'] ?? '',
+                'max_order_total' => $result['max_order_total'] ?? '',
+            ]);
+        } catch (\Exception $e) {
+            \Log::channel('payment')->error("{$code} updateLimits error: " . $e->getMessage());
+
+            return response()->json([
+                'success' => false,
+                'message' => $e->getMessage(),
+            ]);
+        }
+    }
+}

+ 12 - 0
packages/Longyi/Pay/Afterpay/src/Http/Controllers/Controller.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace Longyi\Pay\Afterpay\Http\Controllers;
+
+use Illuminate\Foundation\Bus\DispatchesJobs;
+use Illuminate\Foundation\Validation\ValidatesRequests;
+use Illuminate\Routing\Controller as BaseController;
+
+class Controller extends BaseController
+{
+    use DispatchesJobs, ValidatesRequests;
+}

+ 7 - 0
packages/Longyi/Pay/Afterpay/src/Http/routes.php

@@ -0,0 +1,7 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\Pay\Afterpay\Http\Controllers\AfterpayController;
+
+Route::get('afterpay/return', [AfterpayController::class, 'return'])->name('afterpay.return');
+Route::post('admin/afterpay/update-limits', [AfterpayController::class, 'updateLimits'])->name('admin.afterpay.update_limits');

+ 294 - 0
packages/Longyi/Pay/Afterpay/src/Payment/Afterpay.php

@@ -0,0 +1,294 @@
+<?php
+
+namespace Longyi\Pay\Afterpay\Payment;
+
+use GuzzleHttp\Client;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Webkul\Payment\Payment\Payment;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Checkout\Facades\Cart;
+use function Symfony\Component\Translation\t;
+
+class Afterpay extends Payment
+{
+    protected $clientId;
+    protected $apikey;
+    protected $secret;
+    protected $currentOrder;
+    protected $currentInput;
+    protected $createPaymentUlr = 'https://global-api-sandbox.afterpay.com/v2/checkouts';
+    protected $capturePaymentUlr = 'https://global-api-sandbox.afterpay.com/v2/payments/capture';
+    protected $configurationUlr = 'https://global-api-sandbox.afterpay.com/v2/configuration';
+
+    public function __construct()
+    {
+        if ($this->getConfigData('mode') == 'live') {
+            $this->createPaymentUlr = 'https://global-api.afterpay.com/v2/checkouts';
+            $this->capturePaymentUlr = 'https://global-api.afterpay.com/v2/payments/capture';
+            $this->configurationUlr = 'https://global-api.afterpay.com/v2/configuration';
+        }
+        $this->clientId = $this->getConfigData('client_id');
+        $this->apikey = $this->getConfigData('secret_id');
+        $this->secret = $this->getConfigData('webhook_id');
+    }
+
+    protected $code  = 'afterpay';
+
+    public function getRedirectUrl()
+    {
+    }
+
+    public function setOrder($order = null): self
+    {
+        $this->currentOrder = $order;
+        return $this;
+    }
+
+    public function getOrder()
+    {
+        return $this->currentOrder;
+    }
+    public function setInput($input = null): self
+    {
+        $this->currentInput = $input;
+        return $this;
+    }
+
+    public function getInput()
+    {
+        return $this->currentInput;
+    }
+    public function createGatewayOrder()
+    {
+        try {
+            return $this->createPayment(
+                $this->getOrder(),
+                ['merchantOrderId' => $this->getOrder()->increment_id],
+                $this->getInput()
+            );
+        } catch (\Exception $e) {
+            throw new \Exception($e->getMessage());
+        }
+    }
+
+    public function createPayment($order, $override, $input = null)
+    {
+        $shop = $order->shipping_address;
+        $bill = $order->billing_address;
+        $telephone = $shop->phone ?: $bill->phone;
+        $grandTotal = round($order->grand_total, 2);
+        foreach ($order->items as $item) {
+            $products[] = [
+                'name' => (string)$item->name,
+                'quantity' => $item->qty_ordered,
+                'sku' => $item->sku,
+                'price' => [
+                    'amount' => $this->formatCurrencyValue((float) $item->price),
+                    'currency' => $order->order_currency_code
+                ],
+            ];
+        }
+        $data = [
+            'amount' => [
+                'amount' => $grandTotal,
+                'currency' => $order->order_currency_code
+            ],
+            'consumer' => [
+                'email' => $order->customer_email,
+                'givenNames' => $order->customer_first_name,
+                'surname' => $order->customer_last_name,
+                'phoneNumber' => $telephone
+            ],
+            'merchantReference' => $override['merchantOrderId'],
+            'billing' => [
+                'name' => $bill->first_name . ' ' .$bill->last_name,
+                'line1' => $bill->address,
+                'area1' => $bill->state,
+                'area2' => $bill->city,
+                'region' => $bill->state,
+                'postcode' => $bill->postcode,
+                'phoneNumber' => $bill->phone,
+                'countryCode' => $bill->country,
+            ],
+            'shipping' => [
+                'name' => $shop->first_name . ' ' .$shop->last_name,
+                'line1' => $shop->address,
+                'area1' => $shop->state,
+                'area2' => $shop->city,
+                'region' => $shop->state,
+                'postcode' => $shop->postcode,
+                'phoneNumber' => $shop->phone,
+                'countryCode' => $shop->country,
+            ],
+            'merchant' => [
+                'redirectConfirmUrl' => $input->paymentSuccessUrl . '?orderId=' . $order->id,
+                'redirectCancelUrl' => $input->paymentCancelUrl. '?orderId=' . $order->id
+            ],
+            'items' => $products
+        ];
+        $client = new Client();
+        $response = $client->request('POST', $this->createPaymentUlr, [
+            'headers' => [
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+                'Authorization' =>  'Basic '.base64_encode("{$this->clientId}:{$this->apikey}")
+            ],
+            'json' => $data,
+            'timeout' => 30,
+            'verify' => false
+        ]);
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('Afterpay createPayment response', [
+            'order_id'  => $order->increment_id ?? null,
+            'status'    => $response->getStatusCode(),
+            'body'      => $responseBody,
+        ]);
+
+        $resultObject = json_decode($responseBody);
+        if (empty($resultObject->token)) {
+            throw new \Exception($resultObject->message);
+        } else {
+            return $resultObject->token;
+        }
+    }
+
+    public function capture($token)
+    {
+        $client = new Client();
+        $response = $client->request('POST', $this->capturePaymentUlr, [
+            'headers' => [
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+                'Authorization' =>  'Basic '.base64_encode("{$this->clientId}:{$this->apikey}")
+            ],
+            'json' => ['token' => $token],
+            'timeout' => 30,
+            'verify' => false
+        ]);
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('Afterpay capture response', [
+            'token'     => $token,
+            'status'    => $response->getStatusCode(),
+            'body'      => $responseBody,
+        ]);
+
+        $resultObject = json_decode($responseBody);
+        if ($resultObject->status == 'APPROVED') {
+            return ['transaction_id' => $resultObject->token, 'amount' => $resultObject->events[0]->amount->amount];
+        } else {
+            throw new \Exception('afterpay capture is error');
+        }
+    }
+
+    public function captureAndVerify($order, $transactionId)
+    {
+        if ($order->status != Order::STATUS_PENDING) {
+            return new \Exception('order status is error');
+        }
+        $additional = $order->payment->additional;
+        $token = $additional['gateway_order_id'];
+        if ($transactionId != $token) {
+            return new \Exception('order token is error');
+        }
+        return $this->capture($token);
+    }
+
+    public function getBaseUlr()
+    {
+        return  config('app.url');
+    }
+
+
+    public function checkSignature($data, $exp)
+    {
+        if (empty($data)) {
+            return false;
+        }
+        if (hash_hmac('sha256', $data['timestamp'].$data['content'], $this->$exp) != $data['signature']) {
+            return false;
+        }
+        return true;
+    }
+
+    public function getImage()
+    {
+        $url = $this->getConfigData('image');
+
+        return $url ? Storage::url($url) : bagisto_asset('images/cash-on-delivery.png', 'shop');
+    }
+
+
+
+    public function fetchConfiguration(string $code = 'afterpay'): ?array
+    {
+        try {
+            $client = new Client();
+            $response = $client->request('GET', $this->configurationUlr, [
+                'headers' => [
+                    'Accept' => 'application/json',
+                    'Content-Type' => 'application/json',
+                    'Authorization' => 'Basic ' . base64_encode("{$this->clientId}:{$this->apikey}")
+                ],
+                'timeout' => 30,
+                'verify'  => false,
+            ]);
+
+            $responseBody = $response->getBody()->getContents();
+            Log::channel('payment')->info('Afterpay fetchConfiguration response', [
+                'code'   => $code,
+                'status' => $response->getStatusCode(),
+                'body'   => $responseBody,
+            ]);
+
+            $result = json_decode($responseBody, true);
+
+            return [
+                'min_order_total' => $result['minimumAmount']['amount'] ?? '',
+                'max_order_total' => $result['maximumAmount']['amount'] ?? '',
+            ];
+        } catch (\Exception $e) {
+            Log::channel('payment')->error('Afterpay fetchConfiguration error: ' . $e->getMessage());
+            throw $e;
+        }
+    }
+
+    public function isAvailable($isShow = false)
+    {
+        if (! parent::isAvailable()) {
+            return false;
+        }
+        if ($isShow) {
+            return true;
+        }
+        $info = $this->getOrder() ?: Cart::getCart();
+        if ($info && $billingCountry = $info->billing_address->country ?? false) {
+            $allowedCountries = ['CA', 'US'];//加拿大,美国
+            if (! in_array($billingCountry, $allowedCountries)) {
+                return false;
+            }
+        }
+
+        // 检查订单金额是否在限额范围内
+//        if ($info) {
+//            $grandTotal = $info->grand_total ?? $info->sub_total ?? 0;
+//            $minOrderTotal = $this->getConfigData('min_order_total');
+//            $maxOrderTotal = $this->getConfigData('max_order_total');
+//
+//            if (! empty($minOrderTotal) && $grandTotal < (float) $minOrderTotal) {
+//                return false;
+//            }
+//            if (! empty($maxOrderTotal) && $grandTotal > (float) $maxOrderTotal) {
+//                return false;
+//            }
+//        }
+
+        return true;
+    }
+
+    public function formatCurrencyValue($number): float
+    {
+        return round((float) $number, 2);
+    }
+}

+ 148 - 0
packages/Longyi/Pay/Afterpay/src/Payment/Clearpay.php

@@ -0,0 +1,148 @@
+<?php
+
+namespace Longyi\Pay\Afterpay\Payment;
+
+use GuzzleHttp\Client;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Webkul\Payment\Payment\Payment;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Checkout\Facades\Cart;
+use function Symfony\Component\Translation\t;
+
+class Clearpay extends Afterpay
+{
+    public function __construct()
+    {
+        parent::__construct();
+        $this->clientId = $this->getConfigData('client_id');
+        $this->apikey = $this->getConfigData('secret_id');
+        $this->secret = $this->getConfigData('webhook_id');
+    }
+
+    protected $code  = 'clearpay';
+
+    public function createGatewayOrder()
+    {
+        try {
+            return $this->createPayment(
+                $this->getOrder(),
+                ['merchantOrderId' => $this->getOrder()->increment_id],
+                $this->getInput()
+            );
+        } catch (\Exception $e) {
+            throw new \Exception($e->getMessage());
+        }
+    }
+
+    public function createPayment($order, $override, $input = null)
+    {
+        $shop = $order->shipping_address;
+        $bill = $order->billing_address;
+        $telephone = $shop->phone ?: $bill->phone;
+        $grandTotal = round($order->grand_total, 2);
+        foreach ($order->items as $item) {
+            $products[] = [
+                'name' => (string)$item->name,
+                'quantity' => $item->qty_ordered,
+                'sku' => $item->sku,
+                'price' => [
+                    'amount' => $this->formatCurrencyValue((float) $item->price),
+                    'currency' => $order->order_currency_code
+                ],
+            ];
+        }
+        $data = [
+            'purchaseCountry' => $shop->country,
+            'amount' => [
+                'amount' => $grandTotal,
+                'currency' => $order->order_currency_code
+            ],
+            'consumer' => [
+                'email' => $order->customer_email,
+                'givenNames' => $order->customer_first_name,
+                'surname' => $order->customer_last_name,
+                'phoneNumber' => $telephone
+            ],
+            'merchantReference' => $override['merchantOrderId'],
+            'billing' => [
+                'name' => $bill->first_name . ' ' .$bill->last_name,
+                'line1' => $bill->address,
+                'area1' => $bill->state,
+                'area2' => $bill->city,
+                'region' => $bill->state,
+                'postcode' => $bill->postcode,
+                'phoneNumber' => $bill->phone,
+                'countryCode' => $bill->country,
+            ],
+            'shipping' => [
+                'name' => $shop->first_name . ' ' .$shop->last_name,
+                'line1' => $shop->address,
+                'area1' => $shop->state,
+                'area2' => $shop->city,
+                'region' => $shop->state,
+                'postcode' => $shop->postcode,
+                'phoneNumber' => $shop->phone,
+                'countryCode' => $shop->country,
+            ],
+            'merchant' => [
+                'redirectConfirmUrl' => $input->paymentSuccessUrl . '?orderId=' . $order->id,
+                'redirectCancelUrl' => $input->paymentCancelUrl. '?orderId=' . $order->id
+            ],
+            'items' => $products
+        ];
+        $client = new Client();
+        $response = $client->request('POST', $this->createPaymentUlr, [
+            'headers' => [
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+                'Authorization' =>  'Basic '.base64_encode("{$this->clientId}:{$this->apikey}")
+            ],
+            'json' => $data,
+            'timeout' => 30,
+            'verify' => false
+        ]);
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('Clearpay createPayment response', [
+            'order_id'  => $order->increment_id ?? null,
+            'status'    => $response->getStatusCode(),
+            'body'      => $responseBody,
+        ]);
+
+        $resultObject = json_decode($responseBody);
+        if (empty($resultObject->token)) {
+            throw new \Exception($resultObject->message);
+        } else {
+            return $resultObject->token;
+        }
+    }
+
+    public function getCountryOptions(): array
+    {
+        return core()->countries()->map(function ($country) {
+            return [
+                'title' => $country->name,
+                'value' => $country->code,
+            ];
+        })->toArray();
+    }
+    public function isAvailable($isShow = true)
+    {
+        if (! parent::isAvailable($isShow)) {
+            return false;
+        }
+        $info = $this->getOrder() ?: Cart::getCart();
+        if ($info && $billingCountry = $info->billing_address->country ?? false) {
+//            $allowedCountries = $this->getConfigData('allowed_countries');
+//            if (empty($allowedCountries)) {
+//                return true;
+//            }
+//            $allowedCountries = explode(',', $allowedCountries);
+//            return in_array($billingCountry, $allowedCountries);
+            $allowedCountries = ['GB'];//英国
+            return in_array($billingCountry, $allowedCountries);
+        }
+        return false;
+    }
+}

+ 30 - 0
packages/Longyi/Pay/Afterpay/src/Providers/AfterpayServiceProvider.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace Longyi\Pay\Afterpay\Providers;
+
+use Illuminate\Support\ServiceProvider;
+
+class AfterpayServiceProvider extends ServiceProvider
+{
+    public function register(): void
+    {
+        $this->registerConfig();
+    }
+
+    public function boot(): void
+    {
+        $this->loadRoutesFrom(__DIR__.'/../Http/routes.php');
+        $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'Afterpay');
+    }
+
+    protected function registerConfig()
+    {
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/payment-methods.php', 'payment_methods'
+        );
+
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/system.php', 'core'
+        );
+    }
+}

+ 9 - 0
packages/Longyi/Pay/Afterpay/src/Resources/views/admin/system/readonly-value.blade.php

@@ -0,0 +1,9 @@
+<div class="flex flex-col gap-1">
+    <span class="text-sm font-medium text-gray-600 dark:text-gray-300">
+        {{ trans($field->getTitle()) }}
+    </span>
+
+    <span class="afterpay-limit-value w-max rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300" data-field="{{ $field->getName() }}">
+        {{ system_config()->getConfigData($field->getNameKey()) ?: '-' }}
+    </span>
+</div>

+ 76 - 0
packages/Longyi/Pay/Afterpay/src/Resources/views/admin/system/update-limits.blade.php

@@ -0,0 +1,76 @@
+@php
+    $uid = str_replace('.', '_', $field->getItemKey());
+    $fnName = 'updateLimits_' . $uid;
+@endphp
+
+<div class="flex items-center gap-2.5">
+    <button
+        type="button"
+        class="primary-button"
+        onclick="{{ $fnName }}()"
+    >
+        <span id="{{ $uid }}_btn_text">Update Limits</span>
+        <span id="{{ $uid }}_btn_loading" class="hidden items-center gap-1">
+            <svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
+                <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
+                <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
+            </svg>
+            Updating...
+        </span>
+    </button>
+    <span
+        id="{{ $uid }}_msg"
+        class="text-sm hidden"
+    ></span>
+</div>
+
+<script>
+    (function () {
+        var uid = '{{ $uid }}';
+        var code = uid.split('_').pop();
+
+        window['{{ $fnName }}'] = function () {
+            var btnText = document.getElementById(uid + '_btn_text');
+            var btnLoading = document.getElementById(uid + '_btn_loading');
+            var msgEl = document.getElementById(uid + '_msg');
+            var section = btnText.closest('.box-shadow');
+
+            btnText.classList.add('hidden');
+            btnLoading.classList.remove('hidden');
+            msgEl.classList.add('hidden');
+
+            fetch('{{ route('admin.afterpay.update_limits') }}?code=' + code, {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                    'X-CSRF-TOKEN': '{{ csrf_token() }}',
+                    'Accept': 'application/json',
+                },
+            })
+            .then(function (res) { return res.json(); })
+            .then(function (data) {
+                if (data.success) {
+                    var minEl = section.querySelector('.afterpay-limit-value[data-field="min_order_total"]');
+                    var maxEl = section.querySelector('.afterpay-limit-value[data-field="max_order_total"]');
+                    if (minEl) minEl.textContent = data.min_order_total || '-';
+                    if (maxEl) maxEl.textContent = data.max_order_total || '-';
+                    msgEl.textContent = data.message;
+                    msgEl.className = 'text-sm text-green-600';
+                } else {
+                    msgEl.textContent = data.message || 'Update failed';
+                    msgEl.className = 'text-sm text-red-600';
+                }
+                msgEl.classList.remove('hidden');
+            })
+            .catch(function () {
+                msgEl.textContent = 'Network error, please try again';
+                msgEl.className = 'text-sm text-red-600';
+                msgEl.classList.remove('hidden');
+            })
+            .finally(function () {
+                btnText.classList.remove('hidden');
+                btnLoading.classList.add('hidden');
+            });
+        };
+    })();
+</script>

+ 12 - 0
packages/Longyi/Pay/Airwallex/src/Config/payment-methods.php

@@ -0,0 +1,12 @@
+<?php
+
+return [
+    'airwallex'  => [
+        'code'        => 'airwallex',
+        'title'       => 'Airwallex',
+        'description' => 'Airwallex',
+        'class'       => 'Longyi\Pay\Airwallex\Payment\Airwallex',
+        'active'      => true,
+        'sort'        => 1,
+    ],
+];

+ 82 - 0
packages/Longyi/Pay/Airwallex/src/Config/system.php

@@ -0,0 +1,82 @@
+<?php
+
+return [
+    [
+        'key'    => 'sales.payment_methods.airwallex',
+        'name'   => 'Airwallex', // use translation
+        'info'   => 'Information about Airwallex', // use translation
+        'sort'   => 8,
+        'fields' => [
+            [
+                'name'          => 'title',
+                'title'         => 'Title', // use translation
+                'type'          => 'text',
+                'validation'    => 'required',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ], [
+                'name'          => 'description',
+                'title'         => 'Description', // use translation
+                'type'          => 'textarea',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ],[
+                'name' => 'mode',
+                'title' => 'Mode',
+                'type' => 'select',
+                'options' => [
+                    [
+                        'title' => 'live',
+                        'value' => 'live',
+                    ], [
+                        'title' => 'test',
+                        'value' => 'test',
+                    ],
+                ],
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'client_id',
+                'title' => 'ClientId',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'secret_id',
+                'title' => 'Secret',
+                'type' => 'password',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'webhook_id',
+                'title' => 'WebhookId',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name'          => 'sort',
+                'title'         => 'Sort Order',
+                'type'          => 'text',
+                'validation'    => 'numeric',
+                'channel_based' => true,
+                'locale_based'  => false,
+                'default'       => 5,
+            ], [
+                'name'          => 'active',
+                'title'         => 'Status', // use translation
+                'type'          => 'boolean',
+                'validation'    => 'required',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ]
+        ]
+    ]
+];

+ 66 - 0
packages/Longyi/Pay/Airwallex/src/Http/Controllers/AirwallexController.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace Longyi\Pay\Airwallex\Http\Controllers;
+
+use Longyi\Pay\Airwallex\Payment\Airwallex;
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Sales\Transformers\OrderResource;
+use Longyi\Pay\AwxKlarna\Payment\AwxKlarna;
+
+class AirwallexController extends Controller
+{
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected OrderRepository $orderRepository,
+        protected Airwallex $airwallex,
+    ) {}
+
+
+    public function hook()
+    {
+        $param = @file_get_contents('php://input');
+        $header = $this->getAllHeaders();
+        $data = [
+            'timestamp' => $header['x-timestamp'],
+            'signature' => $header['x-signature'],
+            'content' => $param
+        ];
+        $result = $this->airwallex->checkSignature($data, 'secret');
+        if (!$result) {
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+        $notifyData = json_decode($param, true);
+        $orderNo = $notifyData['data']['object']['merchant_order_id'] ?: 0;
+        if (empty($orderNo)) {
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+        $prefix = $this->airwallex->getPrefix();
+        if (strpos($orderNo, $prefix) === false) {
+            @header("HTTP/1.0 200 ok");
+            exit;
+        }
+        $type = $notifyData['name'];
+        $orderId = str_replace($prefix, '', $orderNo);
+        $transactionId = $notifyData['data']['object']['id'] ?: '';
+        switch ($type) {
+            case 'payment_intent.requires_capture':
+            case 'payment_intent.succeeded':
+                $this->airwallex->paymentSucceeded($orderId, $transactionId);
+                @header("HTTP/1.0 200 ok");
+                break;
+            default:
+                // 对于未知事件类型,记录日志但仍返回成功
+                @header("HTTP/1.0 200 ok");
+                break;
+        }
+    }
+
+}

+ 12 - 0
packages/Longyi/Pay/Airwallex/src/Http/Controllers/Controller.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace Longyi\Pay\Airwallex\Http\Controllers;
+
+use Illuminate\Foundation\Bus\DispatchesJobs;
+use Illuminate\Foundation\Validation\ValidatesRequests;
+use Illuminate\Routing\Controller as BaseController;
+
+class Controller extends BaseController
+{
+    use DispatchesJobs, ValidatesRequests;
+}

+ 7 - 0
packages/Longyi/Pay/Airwallex/src/Http/routes.php

@@ -0,0 +1,7 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\Pay\Airwallex\Http\Controllers\AirwallexController;
+
+// Webhook 路由(独立于 web 中间件,避免 CSRF 验证)
+Route::post('airwallex/webhook/hook', [AirwallexController::class, 'hook'])->name('airwallex.webhook.hook');

+ 262 - 0
packages/Longyi/Pay/Airwallex/src/Payment/Airwallex.php

@@ -0,0 +1,262 @@
+<?php
+
+namespace Longyi\Pay\Airwallex\Payment;
+
+use GuzzleHttp\Client;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Payment\Payment\Payment;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Sales\Repositories\InvoiceRepository;
+
+class Airwallex extends Payment
+{
+    /**
+     * Payment method code
+     *
+     * @var string
+     */
+    protected $code  = 'airwallex';
+    protected $clientId;
+    protected $apikey;
+    protected $secret;
+    protected $currentOrder;
+    protected $prefix = 'AS';
+    protected $tokenCacheKey = 'airwallex_airwallex_token';
+    protected $tokenUrl = 'https://api-demo.airwallex.com/api/v1/authentication/login';
+    protected $createPaymentUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create';
+    protected $confirmIntentsUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/{id}/confirm';
+    protected $captureIntentsUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/{id}/capture';
+    protected $paymentIntentsUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/';
+
+    public function __construct(
+        protected InvoiceRepository $invoiceRepository,
+        protected OrderRepository $orderRepository,
+    )
+    {
+        if ($this->getConfigData('mode') == 'live') {
+            $this->tokenUrl = 'https://api.airwallex.com/api/v1/authentication/login';
+            $this->createPaymentUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/create';
+            $this->confirmIntentsUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/{id}/confirm';
+            $this->captureIntentsUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/{id}/capture';
+            $this->paymentIntentsUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/';
+        }
+        $this->clientId = $this->getConfigData('client_id');
+        $this->apikey = $this->getConfigData('secret_id');
+        $this->secret = $this->getConfigData('webhook_id');
+    }
+
+
+    /**
+     * Get redirect url.
+     */
+    public function getRedirectUrl()
+    {
+    }
+    public function setOrder($order = null): self
+    {
+        $this->currentOrder = $order;
+        return $this;
+    }
+    public function getOrder()
+    {
+        return $this->currentOrder;
+    }
+    public function createGatewayOrder()
+    {
+        try {
+            return $this->createIntents($this->getOrder(), ['merchantOrderId' => $this->getOrder()->increment_id]);
+        } catch (\Exception $e) {
+            throw new \Exception($e->getMessage());
+        }
+    }
+
+    public function createIntents($order, $override)
+    {
+        $shop = $order->shipping_address;
+        $products = [];
+        foreach ($order->items as $item) {
+            $data = [
+                'name' => $item->name,
+                'desc' => $item->name,
+                'unit_price' => (float) $item->price,
+                'sku' => $item->sku,
+                'quantity' => $item->qty_ordered
+            ];
+            array_push($products, $data);
+        }
+        $grandTotal = round($order->grand_total, 2);
+        $data = [
+            'request_id' => $this->createUuid(),
+            'amount' => $grandTotal,
+            'descriptor' => $this->prefix,
+            'merchant_order_id' => $this->prefix . $override['merchantOrderId'],
+            'currency' => $order->order_currency_code,
+            'customer' => [
+                'email' => $order->customer_email,
+                'first_name' => $order->customer_first_name,
+                'last_name' => $order->customer_last_name
+            ],
+            'order' => [
+                'products' => $products,
+                'shipping' => [
+                    'address' => [
+                        'city' => $shop->city,
+                        'postcode' => $shop->postcode,
+                        'state' => $shop->state,
+                        'country_code'=> $shop->country,
+                        'street' => $shop->address,
+                    ],
+                    'first_name' => $shop->first_name,
+                    'last_name' => $shop->last_name,
+                    'phone_number' => $shop->phone,
+                    'email' => $shop->email
+                ],
+            ],
+        ];
+        $client = new Client();
+        $response = $client->request('POST', $this->createPaymentUlr, [
+            'headers' => [
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+                'Authorization' =>  'Bearer '.$this->getToken()
+            ],
+            'json' => $data,
+            'timeout' => 30,
+            'verify' => false
+        ]);
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('airwallex createIntents response', [
+            'paymentIntentId'  => $paymentIntentId ?? null,
+            'order'  => $order->increment_id ?? null,
+            'status'    => $response->getStatusCode(),
+            'body'      => $responseBody,
+        ]);
+        $resultObject = json_decode($responseBody);
+        if (empty($resultObject->id)) {
+            throw new \Exception('airwallex api is error');
+        }
+        $id = $resultObject->id;
+        $client_secret = $resultObject->client_secret;
+        return $id . ':' . $client_secret;
+    }
+
+    public function getToken()
+    {
+        $token = cache()->get($this->tokenCacheKey);
+        if (!$token) {
+            $client = new Client();
+            $response = $client->request('POST', $this->tokenUrl, [
+                'headers' => [
+                    'Accept' => 'application/json',
+                    'Content-Type' => 'application/json',
+                    'x-client-id' => $this->clientId,
+                    'x-api-key' => $this->apikey
+                ],
+                'timeout' => 30,
+                'verify' => false
+            ]);
+            $result = json_decode($response->getBody()->getContents());
+            $token = $result->token;
+            cache()->put($this->tokenCacheKey, $token, 20);
+        }
+        return $token;
+
+    }
+
+    //成功修改状态
+    public function paymentSucceeded($orderId, $transactionId)
+    {
+        $order = $this->orderRepository->find($orderId);
+
+        if (!$order->id) {
+            return false;
+        }
+        if ($order->status != Order::STATUS_PENDING) {
+            return false;
+        }
+        $order->status = Order::STATUS_PROCESSING;
+        $order->save();
+        if ($order->payment) {
+            $additional = $order->payment->additional;
+            $additional['transaction_id'] = $transactionId;
+            $order->payment->additional = $additional;
+            $order->payment->save();
+        }
+        if ($order->canInvoice()) {
+            $this->invoiceRepository->create($this->prepareInvoiceData($order));
+        }
+    }
+    /**
+     * Prepares order's invoice data for creation.
+     */
+    protected function prepareInvoiceData($order): array
+    {
+        $invoiceData = [
+            'order_id' => $order->id,
+            'invoice'  => ['items' => []],
+        ];
+
+        foreach ($order->items as $item) {
+            $invoiceData['invoice']['items'][$item->id] = $item->qty_to_invoice;
+        }
+
+        return $invoiceData;
+    }
+    /**
+     * 获取一个唯一的id
+     */
+    public function createUuid($prefix = "")
+    {
+        $chars = md5(uniqid(mt_rand(), true));
+        $uuid = substr($chars, 0, 8) . '-'
+            . substr($chars, 8, 4) . '-'
+            . substr($chars, 12, 4) . '-'
+            . substr($chars, 16, 4) . '-'
+            . substr($chars, 20, 12);
+        return $prefix . $uuid;
+    }
+
+    public function checkSignature($data, $exp)
+    {
+        if (empty($data)) {
+            return false;
+        }
+        if (hash_hmac('sha256', $data['timestamp'].$data['content'], $this->$exp) != $data['signature']) {
+            return false;
+        }
+        return true;
+    }
+
+    public function getPrefix()
+    {
+        return $this->prefix;
+    }
+
+    /**
+     * Get payment method image.
+     *
+     * @return array
+     */
+    public function getImage()
+    {
+        $url = $this->getConfigData('image');
+
+        return $url ? Storage::url($url) : bagisto_asset('images/cash-on-delivery.png', 'shop');
+    }
+
+    public function isAvailable()
+    {
+        if (! parent::isAvailable()) {
+            return false;
+        }
+        $info = $this->getOrder() ?: Cart::getCart();
+        if ($info && $billingCountry = $info->billing_address->country ?? false) {
+            $allowedCountries = ['AU', 'CA', 'NZ', 'GB', 'US'];
+            return in_array($billingCountry, $allowedCountries);
+        }
+        return true;
+    }
+}

+ 41 - 0
packages/Longyi/Pay/Airwallex/src/Providers/AirwallexServiceProvider.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Longyi\Pay\Airwallex\Providers;
+
+use Illuminate\Support\ServiceProvider;
+
+class AirwallexServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     */
+    public function register(): void
+    {
+        $this->registerConfig();
+    }
+
+    /**
+     * Bootstrap services.
+     */
+    public function boot(): void
+    {
+        // 加载路由
+        $this->loadRoutesFrom(__DIR__ . '/../Http/routes.php');
+    }
+
+    /**
+     * Register package config.
+     *
+     * @return void
+     */
+    protected function registerConfig()
+    {
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/payment-methods.php', 'payment_methods'
+        );
+
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/system.php', 'core'
+        );
+    }
+}

+ 12 - 0
packages/Longyi/Pay/Applepay/src/Config/payment-methods.php

@@ -0,0 +1,12 @@
+<?php
+
+return [
+    'applepay'  => [
+        'code'        => 'applepay',
+        'title'       => 'Applepay',
+        'description' => 'Applepay',
+        'class'       => 'Longyi\Pay\Applepay\Payment\Applepay',
+        'active'      => true,
+        'sort'        => 1,
+    ],
+];

+ 65 - 0
packages/Longyi/Pay/Applepay/src/Config/system.php

@@ -0,0 +1,65 @@
+<?php
+
+return [
+    [
+        'key'    => 'sales.payment_methods.applepay',
+        'name'   => 'Applepay', // use translation
+        'info'   => 'Information about Applepay', // use translation
+        'sort'   => 7,
+        'fields' => [
+            [
+                'name'          => 'title',
+                'title'         => 'Title', // use translation
+                'type'          => 'text',
+                'validation'    => 'required',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ], [
+                'name'          => 'description',
+                'title'         => 'Description', // use translation
+                'type'          => 'textarea',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ], [
+                'name' => 'client_id',
+                'title' => 'ClientId',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'secret_id',
+                'title' => 'Secret',
+                'type' => 'password',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'webhook_id',
+                'title' => 'WebhookId',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name'          => 'sort',
+                'title'         => 'Sort Order',
+                'type'          => 'text',
+                'validation'    => 'numeric',
+                'channel_based' => true,
+                'locale_based'  => false,
+                'default'       => 5,
+            ], [
+                'name'          => 'active',
+                'title'         => 'Status', // use translation
+                'type'          => 'boolean',
+                'validation'    => 'required',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ]
+        ]
+    ]
+];

+ 48 - 0
packages/Longyi/Pay/Applepay/src/Http/Controllers/ApplepayController.php

@@ -0,0 +1,48 @@
+<?php
+
+namespace Longyi\Pay\Applepay\Http\Controllers;
+
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Sales\Transformers\OrderResource;
+use Longyi\Pay\Applepay\Payment\Applepay;
+
+class ApplepayController extends Controller
+{
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected OrderRepository $orderRepository,
+        protected Applepay $applepay,
+    ) {}
+
+    public function hook()
+    {
+        $header = request()->header();
+        $rawBody = request()->getContent();
+        $event = json_decode($rawBody, true);
+        $signatureResult = $this->applepay->webhookSignature($header, $event);
+        if (!$signatureResult) {
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+        $orderId = $event['resource']['invoice_id'];
+        $transactionId = $event['resource']['id'];
+        $type = $event['event_type'];
+        switch ($type) {
+            case 'PAYMENT.CAPTURE.COMPLETED':
+                $this->applepay->paymentSucceeded($orderId, $transactionId);
+                @header("HTTP/1.0 200 ok");
+                break;
+            default:
+                // 对于未知事件类型,记录日志但仍返回成功
+                @header("HTTP/1.0 200 ok");
+                break;
+        }
+    }
+
+}

+ 12 - 0
packages/Longyi/Pay/Applepay/src/Http/Controllers/Controller.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace Longyi\Pay\Applepay\Http\Controllers;
+
+use Illuminate\Foundation\Bus\DispatchesJobs;
+use Illuminate\Foundation\Validation\ValidatesRequests;
+use Illuminate\Routing\Controller as BaseController;
+
+class Controller extends BaseController
+{
+    use DispatchesJobs, ValidatesRequests;
+}

+ 8 - 0
packages/Longyi/Pay/Applepay/src/Http/routes.php

@@ -0,0 +1,8 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\Pay\Applepay\Http\Controllers\ApplepayController;
+
+
+// Webhook 路由(独立于 web 中间件,避免 CSRF 验证)
+Route::post('applepay/webhook/hook', [ApplepayController::class, 'hook'])->name('applepay.webhook.hook');

+ 269 - 0
packages/Longyi/Pay/Applepay/src/Payment/Applepay.php

@@ -0,0 +1,269 @@
+<?php
+
+namespace Longyi\Pay\Applepay\Payment;
+
+use GuzzleHttp\Client;
+use Webkul\Payment\Payment\Payment;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Sales\Repositories\InvoiceRepository;
+
+class Applepay extends Payment
+{
+    /**
+     * Payment method code
+     *
+     * @var string
+     */
+    protected $code  = 'applepay';
+    protected $clientId;
+    protected $apikey;
+    protected $webhookId;
+    protected $currentOrder;
+    protected $prefix = 'QQS';
+    public $createOrderApi = 'https://api-m.sandbox.paypal.com/v2/checkout/orders';
+    public $captureOrderApi = 'https://api-m.sandbox.paypal.com/v2/checkout/orders/{id}/capture';
+    public $detailOrderApi = 'https://api-m.sandbox.paypal.com/v2/checkout/orders/{id}';
+    public $updateOrderApi = 'https://api-m.sandbox.paypal.com/v2/checkout/orders/{id}';
+    public $sigApi = 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature';
+    public $orderTrackApi = 'https://api-m.sandbox.paypal.com/v2/checkout/orders/{id}/track';
+    public $addTrackApi = 'https://api-m.sandbox.paypal.com/v1/shipping/trackers-batch';
+
+    public function __construct(
+        protected InvoiceRepository $invoiceRepository,
+        protected OrderRepository $orderRepository,
+    )
+    {
+        if ($this->getConfigData('mode') == 'live') {
+            $this->createOrderApi = 'https://api-m.paypal.com/v2/checkout/orders';
+            $this->captureOrderApi = 'https://api-m.paypal.com/v2/checkout/orders/{id}/capture';
+            $this->detailOrderApi = 'https://api-m.paypal.com/v2/checkout/orders/{id}';
+            $this->updateOrderApi = 'https://api-m.paypal.com/v2/checkout/orders/{id}';
+            $this->sigApi = 'https://api-m.paypal.com/v1/notifications/verify-webhook-signature';
+            $this->orderTrackApi = 'https://api-m.paypal.com/v2/checkout/orders/{id}/track';
+            $this->addTrackApi = 'https://api-m.paypal.com/v1/shipping/trackers-batch';
+        }
+        $this->clientId = $this->getConfigData('client_id');
+        $this->apikey = $this->getConfigData('secret_id');
+        $this->webhookId = $this->getConfigData('webhook_id');
+    }
+    /**
+     * Get redirect url.
+     */
+    public function getRedirectUrl()
+    {
+
+    }
+    public function setOrder($order = null): self
+    {
+        $this->currentOrder = $order;
+
+        return $this;
+    }
+    public function getOrder()
+    {
+        return $this->currentOrder;
+    }
+    public function createGatewayOrder()
+    {
+        return $this->createOrder($this->getOrder(), ['merchantOrderId' => $this->getOrder()->id]);
+    }
+    public function createOrder($order, $override)
+    {
+        $billingAddressLines = $this->getAddressLines($order->billing_address->address);
+        $data = [
+            'intent' => 'AUTHORIZE',
+            'purchase_units' => [
+                [
+                    'reference_id' => $override['merchantOrderId'],
+                    'amount' => [
+                        'currency_code' => $order->order_currency_code,
+                        'value' => $this->formatCurrencyValue((float) $order->sub_total + $order->tax_total + ($order->selected_shipping_rate ? $order->selected_shipping_rate->price : 0) - $order->discount_amount),
+                        'breakdown' => [
+                            'item_total' => [
+                                'currency_code' => $order->order_currency_code,
+                                'value'         => $this->formatCurrencyValue((float) $order->sub_total),
+                            ],
+
+                            'shipping'   => [
+                                'currency_code' => $order->order_currency_code,
+                                'value'         => $this->formatCurrencyValue((float) ($order->selected_shipping_rate ? $order->selected_shipping_rate->price : 0)),
+                            ],
+
+                            'tax_total'  => [
+                                'currency_code' => $order->order_currency_code,
+                                'value'         => $this->formatCurrencyValue((float) $order->tax_total),
+                            ],
+
+                            'discount'   => [
+                                'currency_code' => $order->order_currency_code,
+                                'value'         => $this->formatCurrencyValue((float) $order->discount_amount),
+                            ],
+                        ],
+                    ],
+                    'items' => $this->getLineItems($order),
+                    'shipping' => [
+                        'address' => [
+                            'address_line_1' => current($billingAddressLines),
+                            'address_line_2' => last($billingAddressLines),
+                            'admin_area_2'   => $order->shipping_address->city,
+                            'admin_area_1'   => $order->shipping_address->state,
+                            'postal_code'    => $order->shipping_address->postcode,
+                            'country_code'   => $order->shipping_address->country,
+                        ],
+                    ],
+                ]
+            ]
+        ];
+        $client = new Client();
+        $response = $client->request('POST', $this->createOrderApi, [
+            'headers' => [
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+                'Authorization' =>  'Basic '.base64_encode("{$this->clientId}:{$this->apikey}")
+            ],
+            'json' => $data,
+            'timeout' => 30,
+            'verify' => false
+        ]);
+        $resultObject = json_decode($response->getBody()->getContents());
+        return $resultObject->id;
+    }
+    /**
+     * Return cart items.
+     *
+     * @param  string  $cart
+     * @return array
+     */
+    protected function getLineItems($order)
+    {
+        $lineItems = [];
+
+        foreach ($order->items as $item) {
+            $lineItems[] = [
+                'unit_amount' => [
+                    'currency_code' => $order->order_currency_code,
+                    'value'         => $this->formatCurrencyValue((float) $item->price),
+                ],
+                'quantity'    => $item->qty_ordered,
+                'name'        => $item->name,
+                'sku'         => $item->sku,
+                'category'    => $item->getTypeInstance()->isStockable() ? 'PHYSICAL_GOODS' : 'DIGITAL_GOODS',
+            ];
+        }
+
+        return $lineItems;
+    }
+
+    public function webhookSignature($header, $data)
+    {
+        $data = [
+            'transmission_id' => $header['Paypal-Transmission-Id'],
+            'transmission_time' => $header['Paypal-Transmission-Time'],
+            'cert_url' => $header['Paypal-Cert-Url'],
+            'auth_algo' => $header['Paypal-Auth-Algo'],
+            'transmission_sig' => $header['Paypal-Transmission-Sig'],
+            'webhook_id' => $this->webhookId,
+            'webhook_event' => $data
+        ];
+        $client = new Client();
+        $response = $client->request('POST', $this->sigApi, [
+            'headers' => [
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+                'Authorization' =>  'Basic '. base64_encode("{$this->apikey}:{$this->secret}")
+            ],
+            'json' => $data,
+            'timeout' => 30,
+            'verify' => false
+        ]);
+        $resultObject = json_decode($response->getBody()->getContents());
+        if ($resultObject->verification_status == 'SUCCESS') {
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    //成功修改状态
+    public function paymentSucceeded($orderId, $transactionId)
+    {
+        $order = $this->orderRepository->find($orderId);
+
+        if (!$order->id) {
+            return false;
+        }
+        if ($order->status != Order::STATUS_PENDING) {
+            return false;
+        }
+        $order->status = Order::STATUS_PROCESSING;
+        $order->save();
+        if ($order->payment) {
+            $additional = $order->payment->additional;
+            $additional['transaction_id'] = $transactionId;
+            $order->payment->additional = $additional;
+            $order->payment->save();
+        }
+        if ($order->canInvoice()) {
+            $this->invoiceRepository->create($this->prepareInvoiceData($order));
+        }
+    }
+    /**
+     * Prepares order's invoice data for creation.
+     */
+    protected function prepareInvoiceData($order): array
+    {
+        $invoiceData = [
+            'order_id' => $order->id,
+            'invoice'  => ['items' => []],
+        ];
+
+        foreach ($order->items as $item) {
+            $invoiceData['invoice']['items'][$item->id] = $item->qty_to_invoice;
+        }
+
+        return $invoiceData;
+    }
+    protected function getAddressLines($address)
+    {
+        $address = explode(PHP_EOL, $address, 2);
+
+        $addressLines = [current($address)];
+
+        if (isset($address[1])) {
+            $addressLines[] = str_replace(["\r\n", "\r", "\n"], ' ', last($address));
+        } else {
+            $addressLines[] = '';
+        }
+
+        return $addressLines;
+    }
+
+    public function formatCurrencyValue($number): float
+    {
+        return round((float) $number, 2);
+    }
+    public function isAvailable()
+    {
+        if (! parent::isAvailable()) {
+            return false;
+        }
+        $userAgent = request()->header('User-Agent');
+        if (! $userAgent) {
+            return false;
+        }
+        $appleDevices = [
+            'iPhone',
+            'iPad',
+            'iPod',
+            'Macintosh',
+            'Mac OS X',
+        ];
+        foreach ($appleDevices as $device) {
+            if (strpos($userAgent, $device) !== false) {
+                return true;
+            }
+        }
+        return true;
+    }
+}

+ 41 - 0
packages/Longyi/Pay/Applepay/src/Providers/ApplepayServiceProvider.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Longyi\Pay\Applepay\Providers;
+
+use Illuminate\Support\ServiceProvider;
+
+class ApplepayServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     */
+    public function register(): void
+    {
+        $this->registerConfig();
+    }
+
+    /**
+     * Bootstrap services.
+     */
+    public function boot(): void
+    {
+        // 加载路由
+        $this->loadRoutesFrom(__DIR__ . '/../Http/routes.php');
+    }
+
+    /**
+     * Register package config.
+     *
+     * @return void
+     */
+    protected function registerConfig()
+    {
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/payment-methods.php', 'payment_methods'
+        );
+
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/system.php', 'core'
+        );
+    }
+}

+ 12 - 0
packages/Longyi/Pay/AwxAfterpay/src/Config/payment-methods.php

@@ -0,0 +1,12 @@
+<?php
+
+return [
+    'awxafterpay'  => [
+        'code'        => 'awxafterpay',
+        'title'       => 'AwxAfterpay',
+        'description' => 'AwxAfterpay',
+        'class'       => 'Longyi\Pay\AwxAfterpay\Payment\AwxAfterpay',
+        'active'      => true,
+        'sort'        => 1,
+    ],
+];

+ 82 - 0
packages/Longyi/Pay/AwxAfterpay/src/Config/system.php

@@ -0,0 +1,82 @@
+<?php
+
+return [
+    [
+        'key'    => 'sales.payment_methods.awxafterpay',
+        'name'   => 'AwxAfterpay', // use translation
+        'info'   => 'Information about AwxAfterpay', // use translation
+        'sort'   => 5,
+        'fields' => [
+            [
+                'name'          => 'title',
+                'title'         => 'Title', // use translation
+                'type'          => 'text',
+                'validation'    => 'required',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ], [
+                'name'          => 'description',
+                'title'         => 'Description', // use translation
+                'type'          => 'textarea',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ], [
+                'name' => 'mode',
+                'title' => 'Mode',
+                'type' => 'select',
+                'options' => [
+                    [
+                        'title' => 'live',
+                        'value' => 'live',
+                    ], [
+                        'title' => 'test',
+                        'value' => 'test',
+                    ],
+                ],
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'client_id',
+                'title' => 'ClientId',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'secret_id',
+                'title' => 'Secret',
+                'type' => 'password',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'webhook_id',
+                'title' => 'WebhookId',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name'          => 'sort',
+                'title'         => 'Sort Order',
+                'type'          => 'text',
+                'validation'    => 'numeric',
+                'channel_based' => true,
+                'locale_based'  => false,
+                'default'       => 5,
+            ], [
+                'name'          => 'active',
+                'title'         => 'Status', // use translation
+                'type'          => 'boolean',
+                'validation'    => 'required',
+                'channel_based' => false,
+                'locale_based'  => true,
+            ]
+        ]
+    ]
+];

+ 131 - 0
packages/Longyi/Pay/AwxAfterpay/src/Http/Controllers/AfterpayController.php

@@ -0,0 +1,131 @@
+<?php
+
+namespace Longyi\Pay\AwxAfterpay\Http\Controllers;
+
+use Longyi\Pay\AwxAfterpay\Payment\AwxAfterpay;
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Sales\Transformers\OrderResource;
+use Longyi\Pay\AwxKlarna\Payment\AwxKlarna;
+
+class AfterpayController extends Controller
+{
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected OrderRepository $orderRepository,
+        protected AwxAfterpay $awxAfterpay,
+    ) {}
+
+    /**
+     * Redirects to the klarna.
+     *
+     * @return \Illuminate\View\View
+     */
+    public function placeOrder()
+    {
+        $cart = Cart::getCart();
+        $data = (new OrderResource($cart))->jsonSerialize();
+        $order = $this->orderRepository->create($data);
+        Cart::deActivateCart();
+        session()->flash('order_id', $order->id);
+        return view('AwxAfterpay::standard-redirect', ['order' => $order]);
+    }
+
+    public function redirect()
+    {
+        $orderId = request()->input('orderId');
+        if (!$orderId) {
+            return response()->json([
+                'error' => 'Order ID is required'
+            ], 400);
+        }
+        $order = $this->orderRepository->find($orderId);
+        if (!$order) {
+            return response()->json([
+                'error' => 'Order not found'
+            ], 404);
+        }
+        if ($order->status != Order::STATUS_PENDING) {
+            return response()->json([
+                'error' => 'No payment required for the order'
+            ], 404);
+        }
+        $cartData = [
+            'shipping_address' => $order->shipping_address ? $order->shipping_address->toArray() : [],
+            'billing_address' => $order->billing_address ? $order->billing_address->toArray() : [],
+            'grand_total' => $order->grand_total,
+            'order_currency_code' => $order->order_currency_code,
+            'customer_email' => $order->customer_email,
+            'customer_first_name' => $order->customer_first_name,
+            'customer_last_name' => $order->customer_last_name
+        ];
+        try {
+            $checkoutUrl = $this->awxAfterpay->createPayment($cartData, [
+                'merchantOrderId' => $order->id
+            ]);
+            if (!$checkoutUrl) {
+                throw new \Exception('Failed to get checkout URL from Airwallex');
+            }
+            return redirect()->away($checkoutUrl);
+        } catch (\Exception $e) {
+            return response()->json([
+                'error' => 'Payment initialization failed: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+    /**
+     * Success payment.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function return()
+    {
+        $awxReturnResult = request()->input('awx_return_result');
+        $orderId = request()->input('orderId');
+        if ($awxReturnResult == 'success') {
+            return redirect()->to('/paymentresult/success?orderId=' . $orderId);
+        } else if ($awxReturnResult == 'cancel') {
+            return redirect()->to('/paymentresult/cancel?orderId=' . $orderId);
+        } else {
+            return redirect()->to('/paymentresult/failure?orderId=' . $orderId);
+        }
+    }
+
+    public function hook()
+    {
+        $param = @file_get_contents('php://input');
+        $header = $this->getAllHeaders();
+        $data = [
+            'timestamp' => $header['x-timestamp'],
+            'signature' => $header['x-signature'],
+            'content' => $param
+        ];
+        $result = $this->awxAfterpay->checkSignature($data, 'secret');
+        if (!$result) {
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+        $notifyData = json_decode($param, true);
+        $orderNo = $notifyData['data']['object']['merchant_order_id'] ?: 0;
+        if (empty($orderNo)) {
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+        $prefix = $this->awxAfterpay->getPrefix();
+        if (strpos($orderNo, $prefix) === false) {
+            @header("HTTP/1.0 200 ok");
+            exit;
+        }
+        $type = $notifyData['name'];
+        $orderId = str_replace($prefix, '', $orderNo);
+        $transactionId = $notifyData['data']['object']['id'] ?: '';
+        $eventType = "payments_webhook_" . strtolower(str_replace(".", "_", $type));
+        $this->awxAfterpay->$eventType($orderId, $transactionId);
+    }
+
+}

+ 12 - 0
packages/Longyi/Pay/AwxAfterpay/src/Http/Controllers/Controller.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace Longyi\Pay\AwxAfterpay\Http\Controllers;
+
+use Illuminate\Foundation\Bus\DispatchesJobs;
+use Illuminate\Foundation\Validation\ValidatesRequests;
+use Illuminate\Routing\Controller as BaseController;
+
+class Controller extends BaseController
+{
+    use DispatchesJobs, ValidatesRequests;
+}

+ 13 - 0
packages/Longyi/Pay/AwxAfterpay/src/Http/routes.php

@@ -0,0 +1,13 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\Pay\AwxAfterpay\Http\Controllers\AfterpayController;
+
+Route::group(['middleware' => ['web']], function () {
+    Route::prefix('afterpay/standard')->group(function () {
+        Route::post('/redirect', [AfterpayController::class, 'redirect'])->name('afterpay.standard.redirect');
+        Route::get('/placeOrder', [AfterpayController::class, 'placeOrder'])->name('afterpay.standard.placeOrder');
+
+        Route::get('/return', [AfterpayController::class, 'return'])->name('afterpay.standard.return');
+    });
+});

+ 324 - 0
packages/Longyi/Pay/AwxAfterpay/src/Payment/AwxAfterpay.php

@@ -0,0 +1,324 @@
+<?php
+
+namespace Longyi\Pay\AwxAfterpay\Payment;
+
+use GuzzleHttp\Client;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Webkul\Payment\Payment\Payment;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Checkout\Facades\Cart;
+
+class AwxAfterpay extends Payment
+{
+    protected $clientId;
+    protected $apikey;
+    protected $secret;
+    protected $currentOrder;
+    protected $currentInput;
+    protected $prefix = 'QQS';
+    protected $tokenCacheKey = 'airwallex_klarna_token';
+    protected $tokenUrl = 'https://api-demo.airwallex.com/api/v1/authentication/login';
+    protected $createPaymentUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create';
+    protected $confirmIntentsUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/{id}/confirm';
+    protected $captureIntentsUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/{id}/capture';
+    protected $paymentIntentsUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/';
+
+    public function __construct()
+    {
+        if ($this->getConfigData('mode') == 'live') {
+            $this->tokenUrl = 'https://api.airwallex.com/api/v1/authentication/login';
+            $this->createPaymentUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/create';
+            $this->confirmIntentsUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/{id}/confirm';
+            $this->captureIntentsUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/{id}/capture';
+            $this->paymentIntentsUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/';
+        }
+        $this->clientId = $this->getConfigData('client_id');
+        $this->apikey = $this->getConfigData('secret_id');
+        $this->secret = $this->getConfigData('webhook_id');
+    }
+    /**
+     * Payment method code
+     *
+     * @var string
+     */
+    protected $code  = 'awxafterpay';
+
+    /**
+     * Get redirect url.
+     */
+    public function getRedirectUrl()
+    {
+        return route('afterpay.standard.placeOrder');
+    }
+    public function setOrder($order = null): self
+    {
+        $this->currentOrder = $order;
+        return $this;
+    }
+
+    public function getOrder()
+    {
+        return $this->currentOrder;
+    }
+    public function setInput($input = null): self
+    {
+        $this->currentInput = $input;
+        return $this;
+    }
+
+    public function getInput()
+    {
+        return $this->currentInput;
+    }
+    public function createGatewayOrder()
+    {
+        return $this->createPayment($this->getOrder(), ['merchantOrderId' => $this->getOrder()->id], $this->currentInput);
+    }
+    public function createPayment($order, $override, $input)
+    {
+        $shop = $order->shipping_address;
+        $bill = $order->billing_address;
+        $telephone = $shop->phone ?: $bill->phone;
+        $grandTotal = round($order->grand_total, 2);
+        $products[] = [
+            'name' => 'total',
+            'unit_price' => $grandTotal,
+            'quantity' => 1
+        ];
+        $data = [
+            'request_id' => $this->createUuid(),
+            'amount' => $grandTotal,
+            'descriptor' => $this->prefix,
+            'return_url' => $input->paymentSuccessUrl. '?orderId=' . $order->id,
+            'merchant_order_id' => $this->prefix . $override['merchantOrderId'],
+            'currency' => $order->order_currency_code,
+            'customer' => [
+                'email' => $order->customer_email,
+                'first_name' => $order->customer_first_name,
+                'last_name' => $order->customer_last_name,
+                'phone_number' => $telephone
+            ],
+//            'metadata' => $cart['items'],
+            'order' => [
+                'products' => $products,
+                'shipping' => [
+                    'address' => [
+                        'city' => $shop->city,
+                        'postcode' => $shop->postcode,
+                        'state' => $shop->state,
+                        'country_code' => $shop->country,
+                        'street' => $shop->address,
+                    ],
+                    'first_name' => $shop->first_name,
+                    'last_name' => $shop->last_name,
+                    'phone_number' => $shop->phone,
+                    'email' => $shop->email
+                ],
+            ]
+        ];
+        $client = new Client();
+        $response = $client->request('POST', $this->createPaymentUlr, [
+            'headers' => [
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+                'Authorization' =>  'Bearer '.$this->getToken()
+            ],
+            'json' => $data,
+            'timeout' => 30,
+            'verify' => false
+        ]);
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('AwxAfterpay capture response', [
+            'order_id'  => $order->increment_id ?? null,
+            'status'    => $response->getStatusCode(),
+            'body'      => $responseBody,
+        ]);
+        $resultObject = json_decode($responseBody);
+        if (empty($resultObject->id)) {
+            throw new \Exception($resultObject->details->original_response_message ?:$resultObject->message);
+        } else {
+            $id = $resultObject->id;
+            $confirmResultObject = $this->confirmRequest($id, $order);
+            if (empty($confirmResultObject->next_action->url)) {
+                throw new \Exception($confirmResultObject->details->original_response_message ?:$confirmResultObject->message);
+            }
+            return $confirmResultObject->next_action->url;
+        }
+    }
+
+    public function confirmRequest($paymentIntentId, $order)
+    {
+        $bill = $order->billing_address;
+        $data = [
+            'request_id' => $this->createUuid(),
+            'payment_method' => [
+                'type' => 'afterpay',
+                'afterpay' => [
+                    'shopper_email' => $bill->email,
+                    'billing' => [
+                        'first_name' => $bill->first_name,
+                        'last_name' => $bill->last_name,
+                        'phone_number' => $bill->phone,
+                        'address' => [
+                            'country_code' => $bill->country,
+                            'city' => $bill->city,
+                            'street' => $bill->address,
+                            'postcode' => $bill->postcode,
+                            'state' => $bill->state
+                        ],
+                    ],
+                ]
+            ]
+        ];
+        $client = new Client();
+        $response = $client->request('POST',
+            str_replace('{id}', $paymentIntentId, $this->confirmIntentsUlr),
+            [
+                'headers' => [
+                    'Accept' => 'application/json',
+                    'Content-Type' => 'application/json',
+                    'Authorization' =>  'Bearer '.$this->getToken()
+                ],
+                'json' => $data,
+                'timeout' => 30,
+                'verify' => false
+            ]);
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('AwxAfterpay capture response', [
+            'paymentIntentId'  => $paymentIntentId ?? null,
+            'order'  => $order->increment_id ?? null,
+            'status'    => $response->getStatusCode(),
+            'body'      => $responseBody,
+        ]);
+        $result = json_decode($responseBody);
+        return $result;
+    }
+
+    public function getToken()
+    {
+        $token = cache()->get($this->tokenCacheKey);
+        if (!$token) {
+            $client = new Client();
+            $response = $client->request('POST', $this->tokenUrl, [
+                'headers' => [
+                    'Accept' => 'application/json',
+                    'Content-Type' => 'application/json',
+                    'x-client-id' => $this->clientId,
+                    'x-api-key' => $this->apikey
+                ],
+                'timeout' => 30,
+                'verify' => false
+            ]);
+            $result = json_decode($response->getBody()->getContents());
+            $token = $result->token;
+            cache()->put($this->tokenCacheKey, $token, 20);
+        }
+        return $token;
+
+    }
+
+    //成功修改状态
+    public function payments_webhook_payment_intent_succeeded($orderId, $transactionId)
+    {
+        $order = app(OrderRepository::class)->find($orderId);
+
+        if (!$order) {
+            return false;
+        }
+        if ($order->status != Order::STATUS_PENDING) {
+            return false;
+        }
+        $order->status = Order::STATUS_PROCESSING;
+        $order->save();
+        if ($order->payment) {
+            $order->payment->update([
+                'transaction_id' => $transactionId,
+            ]);
+        }
+    }
+    //授权成功修改状态
+    public function payments_webhook_payment_intent_requires_capture($orderId, $transactionId)
+    {
+        $order = app(OrderRepository::class)->find($orderId);
+
+        if (!$order) {
+            return false;
+        }
+        if ($order->status != Order::STATUS_PENDING) {
+            return false;
+        }
+        $order->status = Order::STATUS_PROCESSING;
+        $order->save();
+        if ($order->payment) {
+            $order->payment->update([
+                'transaction_id' => $transactionId,
+            ]);
+        }
+    }
+    /**
+     * 获取一个唯一的id
+     */
+    public function createUuid($prefix = "")
+    {
+        $chars = md5(uniqid(mt_rand(), true));
+        $uuid = substr($chars, 0, 8) . '-'
+            . substr($chars, 8, 4) . '-'
+            . substr($chars, 12, 4) . '-'
+            . substr($chars, 16, 4) . '-'
+            . substr($chars, 20, 12);
+        return $prefix . $uuid;
+    }
+
+    public function getBaseUlr()
+    {
+        return  config('app.url');
+    }
+
+    public function getAwxAfterpayUrl()
+    {
+        return route('afterpay.standard.redirect');
+    }
+
+    public function checkSignature($data, $exp)
+    {
+        if (empty($data)) {
+            return false;
+        }
+        if (hash_hmac('sha256', $data['timestamp'].$data['content'], $this->$exp) != $data['signature']) {
+            return false;
+        }
+        return true;
+    }
+
+    public function getPrefix()
+    {
+        return $this->prefix;
+    }
+
+    /**
+     * Get payment method image.
+     *
+     * @return array
+     */
+    public function getImage()
+    {
+        $url = $this->getConfigData('image');
+
+        return $url ? Storage::url($url) : bagisto_asset('images/cash-on-delivery.png', 'shop');
+    }
+
+    public function isAvailable()
+    {
+        if (! parent::isAvailable()) {
+            return false;
+        }
+        $info = $this->getOrder() ?: Cart::getCart();
+        if ($info && $billingCountry = $info->billing_address->country ?? false) {
+            $allowedCountries = ['AU', 'CA', 'NZ', 'GB', 'US'];
+            return in_array($billingCountry, $allowedCountries);
+        }
+        return true;
+    }
+}

+ 41 - 0
packages/Longyi/Pay/AwxAfterpay/src/Providers/AwxAfterpayServiceProvider.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Longyi\Pay\AwxAfterpay\Providers;
+
+use Illuminate\Support\ServiceProvider;
+
+class AwxAfterpayServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     */
+    public function register(): void
+    {
+        $this->registerConfig();
+    }
+
+    /**
+     * Bootstrap services.
+     */
+    public function boot(): void
+    {
+        $this->loadRoutesFrom(__DIR__.'/../Http/routes.php');
+        $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'AwxAfterpay');
+    }
+
+    /**
+     * Register package config.
+     *
+     * @return void
+     */
+    protected function registerConfig()
+    {
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/payment-methods.php', 'payment_methods'
+        );
+
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/system.php', 'core'
+        );
+    }
+}

+ 112 - 0
packages/Longyi/Pay/AwxAfterpay/src/Resources/views/checkout/onepage/paypal-smart-button.blade.php

@@ -0,0 +1,112 @@
+@if (
+    request()->routeIs('shop.checkout.onepage.index')
+    && (bool) core()->getConfigData('sales.payment_methods.paypal_smart_button.active')
+)
+    @php
+        $clientId = core()->getConfigData('sales.payment_methods.paypal_smart_button.client_id');
+
+        $acceptedCurrency = core()->getConfigData('sales.payment_methods.paypal_smart_button.accepted_currencies');
+
+        $currentCurrency = core()->getCurrentCurrencyCode();
+
+        $acceptedCurrenciesArray = array_map('trim', explode(',', $acceptedCurrency));
+
+        $currencyToUse = in_array($currentCurrency, $acceptedCurrenciesArray)
+            ? $currentCurrency
+            : $acceptedCurrenciesArray[0];
+    @endphp
+
+    @pushOnce('scripts')
+        <script
+            src="https://www.paypal.com/sdk/js?client-id={{ $clientId }}&currency={{ $currencyToUse }}"
+            data-partner-attribution-id="Bagisto_Cart"
+        >
+        </script>
+
+        <script
+            type="text/x-template"
+            id="v-paypal-smart-button-template"
+        >
+            <div class="w-full paypal-button-container"></div>
+        </script>
+
+        <script type="module">
+            app.component('v-paypal-smart-button', {
+                template: '#v-paypal-smart-button-template',
+
+                mounted() {
+                    this.register();
+                },
+
+                methods: {
+                    register() {
+                        if (typeof paypal == 'undefined') {
+                            this.$emitter.emit('add-flash', { type: 'error', message: '@lang('paypal::app.errors.invalid-configs')' });
+
+                            return;
+                        }
+
+                        paypal.Buttons(this.getOptions()).render('.paypal-button-container');
+                    },
+
+                    getOptions() {
+                        let options = {
+                            style: {
+                                layout: 'vertical',
+                                shape: 'rect',
+                            },
+
+                            authorizationFailed: false,
+
+                            enableStandardCardFields: false,
+
+                            alertBox: (message) => {
+                                this.$emitter.emit('add-flash', { type: 'error', message: message });
+                            },
+
+                            createOrder: (data, actions) => {
+                                return this.$axios.get("{{ route('paypal.smart-button.create-order') }}")
+                                    .then(response => response.data.result)
+                                    .then(order => order.id)
+                                    .catch(error => {
+                                        if (error.response.data.error === 'invalid_client') {
+                                            options.authorizationFailed = true;
+
+                                            options.alertBox('@lang('paypal::app.errors.invalid-configs')');
+                                        }
+
+                                        return error;
+                                    });
+                            },
+
+                            onApprove: (data, actions) => {
+                                this.$axios.post("{{ route('paypal.smart-button.capture-order') }}", {
+                                    _token: "{{ csrf_token() }}",
+                                    orderData: data
+                                })
+                                .then(response => {
+                                    if (response.data.success) {
+                                        if (response.data.redirect_url) {
+                                            window.location.href = response.data.redirect_url;
+                                        } else {
+                                            window.location.href = "{{ route('shop.checkout.onepage.success') }}";
+                                        }
+                                    }
+                                })
+                                .catch(error => window.location.href = "{{ route('shop.checkout.cart.index') }}");
+                            },
+
+                            onError: (error) => {
+                                if (! options.authorizationFailed) {
+                                    options.alertBox('@lang('paypal::app.errors.something-went-wrong')');
+                                }
+                            },
+                        };
+
+                        return options;
+                    },
+                },
+            });
+        </script>
+    @endPushOnce
+@endif

+ 16 - 0
packages/Longyi/Pay/AwxAfterpay/src/Resources/views/standard-redirect.blade.php

@@ -0,0 +1,16 @@
+<?php $awxAfterpayStandard = app('Longyi\Pay\AwxAfterpay\Payment\AwxAfterpay') ?>
+
+<body data-gr-c-s-loaded="true" cz-shortcut-listen="true">
+    You will be redirected to the afterpay website in a few seconds.
+
+
+    <form action="{{ $awxAfterpayStandard->getAwxAfterpayUrl() }}" id="afterpay_standard_checkout" method="POST">
+        <input value="Click here if you are not redirected within 10 seconds..." type="submit">
+        <input type="hidden" name="_token" value="{{ csrf_token() }}">
+        <input type="hidden" name="orderId" value="{{ $order->id }}">
+    </form>
+
+    <script type="text/javascript">
+        document.getElementById("afterpay_standard_checkout").submit();
+    </script>
+</body>

+ 12 - 0
packages/Longyi/Pay/AwxKlarna/src/Config/payment-methods.php

@@ -0,0 +1,12 @@
+<?php
+
+return [
+    'awxklarna'  => [
+        'code'        => 'awxklarna',
+        'title'       => 'AwxKlarna',
+        'description' => 'AwxKlarna',
+        'class'       => 'Longyi\Pay\AwxKlarna\Payment\AwxKlarna',
+        'active'      => true,
+        'sort'        => 1,
+    ],
+];

+ 82 - 0
packages/Longyi/Pay/AwxKlarna/src/Config/system.php

@@ -0,0 +1,82 @@
+<?php
+
+return [
+    [
+        'key' => 'sales.payment_methods.awxklarna',
+        'name' => 'AwxKlarna', // use translation
+        'info' => 'Information about AwxKlarna', // use translation
+        'sort' => 6,
+        'fields' => [
+            [
+                'name' => 'title',
+                'title' => 'Title', // use translation
+                'type' => 'text',
+                'validation' => 'required',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'description',
+                'title' => 'Description', // use translation
+                'type' => 'textarea',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'mode',
+                'title' => 'Mode',
+                'type' => 'select',
+                'options' => [
+                    [
+                        'title' => 'live',
+                        'value' => 'live',
+                    ], [
+                        'title' => 'test',
+                        'value' => 'test',
+                    ],
+                ],
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'client_id',
+                'title' => 'ClientId',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'secret_id',
+                'title' => 'Secret',
+                'type' => 'password',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name' => 'webhook_id',
+                'title' => 'WebhookId',
+                'type' => 'text',
+                'depend' => 'active:1',
+                'validation' => 'required_if:active,1',
+                'channel_based' => false,
+                'locale_based' => true,
+            ], [
+                'name'          => 'sort',
+                'title'         => 'Sort Order',
+                'type'          => 'text',
+                'validation'    => 'numeric',
+                'channel_based' => true,
+                'locale_based'  => false,
+                'default'       => 6,
+            ], [
+                'name' => 'active',
+                'title' => 'Status', // use translation
+                'type' => 'boolean',
+                'validation' => 'required',
+                'channel_based' => false,
+                'locale_based' => true,
+            ]
+        ]
+    ]
+];

+ 12 - 0
packages/Longyi/Pay/AwxKlarna/src/Http/Controllers/Controller.php

@@ -0,0 +1,12 @@
+<?php
+
+namespace Longyi\Pay\AwxKlarna\Http\Controllers;
+
+use Illuminate\Foundation\Bus\DispatchesJobs;
+use Illuminate\Foundation\Validation\ValidatesRequests;
+use Illuminate\Routing\Controller as BaseController;
+
+class Controller extends BaseController
+{
+    use DispatchesJobs, ValidatesRequests;
+}

+ 130 - 0
packages/Longyi/Pay/AwxKlarna/src/Http/Controllers/KlarnaController.php

@@ -0,0 +1,130 @@
+<?php
+
+namespace Longyi\Pay\AwxKlarna\Http\Controllers;
+
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\OrderRepository;
+use Webkul\Sales\Transformers\OrderResource;
+use Longyi\Pay\AwxKlarna\Payment\AwxKlarna;
+
+class KlarnaController extends Controller
+{
+    /**
+     * Create a new controller instance.
+     *
+     * @return void
+     */
+    public function __construct(
+        protected OrderRepository $orderRepository,
+        protected AwxKlarna $awxKlarna,
+    ) {}
+
+    /**
+     * Redirects to the klarna.
+     *
+     * @return \Illuminate\View\View
+     */
+    public function placeOrder()
+    {
+        $cart = Cart::getCart();
+        $data = (new OrderResource($cart))->jsonSerialize();
+        $order = $this->orderRepository->create($data);
+        Cart::deActivateCart();
+        session()->flash('order_id', $order->id);
+        return view('AwxKlarna::standard-redirect', ['order' => $order]);
+    }
+
+    public function redirect()
+    {
+        $orderId = request()->input('orderId');
+        if (!$orderId) {
+            return response()->json([
+                'error' => 'Order ID is required'
+            ], 400);
+        }
+        $order = $this->orderRepository->find($orderId);
+        if (!$order) {
+            return response()->json([
+                'error' => 'Order not found'
+            ], 404);
+        }
+        if ($order->status != Order::STATUS_PENDING) {
+            return response()->json([
+                'error' => 'No payment required for the order'
+            ], 404);
+        }
+        $cartData = [
+            'shipping_address' => $order->shipping_address ? $order->shipping_address->toArray() : [],
+            'billing_address' => $order->billing_address ? $order->billing_address->toArray() : [],
+            'grand_total' => $order->grand_total,
+            'order_currency_code' => $order->order_currency_code,
+            'customer_email' => $order->customer_email,
+            'customer_first_name' => $order->customer_first_name,
+            'customer_last_name' => $order->customer_last_name
+        ];
+        try {
+            $checkoutUrl = $this->awxKlarna->createPayment($cartData, [
+                'merchantOrderId' => $order->id
+            ]);
+            if (!$checkoutUrl) {
+                throw new \Exception('Failed to get checkout URL from Airwallex');
+            }
+            return redirect()->away($checkoutUrl);
+        } catch (\Exception $e) {
+            return response()->json([
+                'error' => 'Payment initialization failed: ' . $e->getMessage()
+            ], 500);
+        }
+    }
+    /**
+     * Success payment.
+     *
+     * @return \Illuminate\Http\Response
+     */
+    public function return()
+    {
+        $awxReturnResult = request()->input('awx_return_result');
+        $orderId = request()->input('orderId');
+        if ($awxReturnResult == 'success') {
+            return redirect()->to('/paymentresult/success?orderId=' . $orderId);
+        } else if ($awxReturnResult == 'cancel') {
+            return redirect()->to('/paymentresult/cancel?orderId=' . $orderId);
+        } else {
+            return redirect()->to('/paymentresult/failure?orderId=' . $orderId);
+        }
+    }
+
+    public function hook()
+    {
+        $param = @file_get_contents('php://input');
+        $header = $this->getAllHeaders();
+        $data = [
+            'timestamp' => $header['x-timestamp'],
+            'signature' => $header['x-signature'],
+            'content' => $param
+        ];
+        $result = $this->awxKlarna->checkSignature($data, 'secret');
+        if (!$result) {
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+        $notifyData = json_decode($param, true);
+        $orderNo = $notifyData['data']['object']['merchant_order_id'] ?: 0;
+        if (empty($orderNo)) {
+            @header("HTTP/1.0 400 Bad Request");
+            exit;
+        }
+        $prefix = $this->awxKlarna->getPrefix();
+        if (strpos($orderNo, $prefix) === false) {
+            @header("HTTP/1.0 200 ok");
+            exit;
+        }
+        $type = $notifyData['name'];
+        $orderId = str_replace($prefix, '', $orderNo);
+        $transactionId = $notifyData['data']['object']['id'] ?: '';
+        $eventType = "payments_webhook_" . strtolower(str_replace(".", "_", $type));
+        $this->awxKlarna->$eventType($orderId, $transactionId);
+    }
+
+}

+ 13 - 0
packages/Longyi/Pay/AwxKlarna/src/Http/routes.php

@@ -0,0 +1,13 @@
+<?php
+
+use Illuminate\Support\Facades\Route;
+use Longyi\Pay\AwxKlarna\Http\Controllers\KlarnaController;
+
+Route::group(['middleware' => ['web']], function () {
+    Route::prefix('klarna/standard')->group(function () {
+        Route::post('/redirect', [KlarnaController::class, 'redirect'])->name('klarna.standard.redirect');
+        Route::get('/placeOrder', [KlarnaController::class, 'placeOrder'])->name('klarna.standard.placeOrder');
+
+        Route::get('/return', [KlarnaController::class, 'return'])->name('klarna.standard.return');
+    });
+});

+ 341 - 0
packages/Longyi/Pay/AwxKlarna/src/Payment/AwxKlarna.php

@@ -0,0 +1,341 @@
+<?php
+
+namespace Longyi\Pay\AwxKlarna\Payment;
+
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Storage;
+use Webkul\Checkout\Facades\Cart;
+use Webkul\Payment\Payment\Payment;
+use GuzzleHttp\Client;
+use Webkul\Sales\Models\Order;
+use Webkul\Sales\Repositories\OrderRepository;
+
+class AwxKlarna extends Payment
+{
+
+    protected $clientId;
+    protected $apikey;
+    protected $secret;
+    protected $currentOrder;
+    protected $currentInput;
+    protected $prefix = 'QQS';
+    protected $tokenCacheKey = 'airwallex_klarna_token';
+    protected $tokenUrl = 'https://api-demo.airwallex.com/api/v1/authentication/login';
+    protected $createPaymentUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/create';
+    protected $confirmIntentsUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/{id}/confirm';
+    protected $captureIntentsUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/{id}/capture';
+    protected $paymentIntentsUlr = 'https://api-demo.airwallex.com/api/v1/pa/payment_intents/';
+
+    public function __construct()
+    {
+        if ($this->getConfigData('mode') == 'live') {
+            $this->tokenUrl = 'https://api.airwallex.com/api/v1/authentication/login';
+            $this->createPaymentUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/create';
+            $this->confirmIntentsUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/{id}/confirm';
+            $this->captureIntentsUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/{id}/capture';
+            $this->paymentIntentsUlr = 'https://api.airwallex.com/api/v1/pa/payment_intents/';
+        }
+        $this->clientId = $this->getConfigData('client_id');
+        $this->apikey = $this->getConfigData('secret_id');
+        $this->secret = $this->getConfigData('webhook_id');
+    }
+
+    /**
+     * Payment method code
+     *
+     * @var string
+     */
+    protected $code = 'awxklarna';
+
+    /**
+     * Get redirect url.
+     */
+    public function getRedirectUrl()
+    {
+        return route('klarna.standard.placeOrder');
+    }
+    public function setOrder($order = null): self
+    {
+        $this->currentOrder = $order;
+        return $this;
+    }
+    public function getOrder()
+    {
+        return $this->currentOrder;
+    }
+    public function setInput($input = null): self
+    {
+        $this->currentInput = $input;
+        return $this;
+    }
+
+    public function getInput()
+    {
+        return $this->currentInput;
+    }
+    public function createGatewayOrder()
+    {
+        return $this->createPayment($this->getOrder(), ['merchantOrderId' => $this->getOrder()->id], $this->getInput());
+    }
+    public function createPayment($order, $override, $input)
+    {
+        $shop = $order->shipping_address;
+        $bill = $order->billing_address;
+        $telephone = $shop['phone'] ?: $bill['phone'];
+        $grandTotal = round($order->grand_total, 2);
+        $products[] = [
+            'name' => 'total',
+            'unit_price' => $grandTotal,
+            'quantity' => 1
+        ];
+        $data = [
+            'request_id' => $this->createUuid(),
+            'amount' => $grandTotal,
+            'descriptor' => $this->prefix,
+            'return_url' => $input->paymentSuccessUrl . '?orderId=' . $order->id,
+            'merchant_order_id' => $this->prefix . $override['merchantOrderId'],
+            'currency' => $order->order_currency_code,
+            'customer' => [
+                'email' => $order->customer_email,
+                'first_name' => $order->customer_first_name,
+                'last_name' => $order->customer_last_name,
+                'phone_number' => $telephone
+            ],
+//            'metadata' => $cart['items'],
+            'order' => [
+                'products' => $products,
+                'shipping' => [
+                    'address' => [
+                        'city' => $shop->city,
+                        'postcode' => $shop->postcode,
+                        'state' => $shop->state,
+                        'country_code' => $shop->country,
+                        'street' => $shop->address,
+                    ],
+                    'first_name' => $shop->first_name,
+                    'last_name' => $shop->last_name,
+                    'phone_number' => $shop->phone,
+                    'email' => $shop->email
+                ],
+            ]
+        ];
+        $client = new Client();
+        $response = $client->request('POST', $this->createPaymentUlr, [
+            'headers' => [
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+                'Authorization' =>  'Bearer '.$this->getToken()
+            ],
+            'json' => $data,
+            'timeout' => 30,
+            'verify' => false
+        ]);
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('AwxKlarna capture response', [
+            'order_id'  => $order->increment_id ?? null,
+            'status'    => $response->getStatusCode(),
+            'body'      => $responseBody,
+        ]);
+        $resultObject = json_decode($responseBody);
+        if (empty($resultObject->id)) {
+            throw new \Exception($resultObject->details->original_response_message ?:$resultObject->message);
+        } else {
+            $id = $resultObject->id;
+            $confirmResultObject = $this->confirmRequest($id, $order);
+            if (empty($confirmResultObject->next_action->url)) {
+                throw new \Exception($confirmResultObject->details->original_response_message ?:$confirmResultObject->message);
+            }
+            return $confirmResultObject->next_action->url;
+        }
+    }
+
+    public function confirmRequest($paymentIntentId, $order)
+    {
+        $bill = $order->billing_address;
+        $data = [
+            'request_id' => $this->createUuid(),
+            'payment_method' => [
+                'type' => 'klarna',
+                'klarna' => [
+                    'country_code' => $bill->country,
+                    'billing' => [
+                        'email' => $bill->email,
+                        'first_name' => $bill->first_name,
+                        'last_name' => $bill->last_name,
+                        'phone_number' => $bill->phone,
+                        'address' => [
+                            'country_code' => $bill->country,
+                            'city' => $bill->city,
+                            'street' => $bill->address,
+                            'postcode' => $bill->postcode,
+                            'state' => $bill->state
+                        ],
+                    ],
+                ],
+                'payment_method_options' => [
+                    'klarna' => [
+                        'auto_capture' => false
+                    ]
+                ]
+            ]
+        ];
+        $client = new Client();
+        $response = $client->request('POST',
+            str_replace('{id}', $paymentIntentId, $this->confirmIntentsUlr),
+            [
+                'headers' => [
+                    'Accept' => 'application/json',
+                    'Content-Type' => 'application/json',
+                    'Authorization' =>  'Bearer '.$this->getToken()
+                ],
+                'json' => $data,
+                'timeout' => 30,
+                'verify' => false
+            ]);
+        $responseBody = $response->getBody()->getContents();
+        Log::channel('payment')->info('AwxKlarna capture response', [
+            'paymentIntentId'  => $paymentIntentId ?? null,
+            'order'  => $order->increment_id ?? null,
+            'status'    => $response->getStatusCode(),
+            'body'      => $responseBody,
+        ]);
+        $result = json_decode($responseBody);
+        return $result;
+    }
+
+    public function getToken()
+    {
+        $token = cache()->get($this->tokenCacheKey);
+        if (!$token) {
+            $client = new Client();
+            $response = $client->request('POST', $this->tokenUrl, [
+                'headers' => [
+                    'Accept' => 'application/json',
+                    'Content-Type' => 'application/json',
+                    'x-client-id' => $this->clientId,
+                    'x-api-key' => $this->apikey
+                ],
+                'timeout' => 30,
+                'verify' => false
+            ]);
+            $result = json_decode($response->getBody()->getContents());
+            $token = $result->token;
+            cache()->put($this->tokenCacheKey, $token, 20);
+        }
+        return $token;
+
+    }
+
+    //成功修改状态
+    public function payments_webhook_payment_intent_succeeded($orderId, $transactionId)
+    {
+        $order = app(OrderRepository::class)->find($orderId);
+
+        if (!$order) {
+            return false;
+        }
+        if ($order->status != Order::STATUS_PENDING) {
+            return false;
+        }
+        $order->status = Order::STATUS_PROCESSING;
+        $order->save();
+        if ($order->payment) {
+            $order->payment->update([
+                'transaction_id' => $transactionId,
+            ]);
+        }
+    }
+    //授权成功修改状态
+    public function payments_webhook_payment_intent_requires_capture($orderId, $transactionId)
+    {
+        $order = app(OrderRepository::class)->find($orderId);
+
+        if (!$order) {
+            return false;
+        }
+        if ($order->status != Order::STATUS_PENDING) {
+            return false;
+        }
+        $order->status = Order::STATUS_PROCESSING;
+        $order->save();
+        if ($order->payment) {
+            $order->payment->update([
+                'transaction_id' => $transactionId,
+            ]);
+        }
+    }
+    /**
+     * 获取一个唯一的id
+     */
+    public function createUuid($prefix = "")
+    {
+        $chars = md5(uniqid(mt_rand(), true));
+        $uuid = substr($chars, 0, 8) . '-'
+            . substr($chars, 8, 4) . '-'
+            . substr($chars, 12, 4) . '-'
+            . substr($chars, 16, 4) . '-'
+            . substr($chars, 20, 12);
+        return $prefix . $uuid;
+    }
+
+    public function getBaseUlr()
+    {
+        return  config('app.url');
+    }
+
+    public function getAwxKlarnaUrl()
+    {
+        return route('klarna.standard.redirect');
+    }
+
+    public function checkSignature($data, $exp)
+    {
+        if (empty($data)) {
+            return false;
+        }
+        if (hash_hmac('sha256', $data['timestamp'].$data['content'], $this->$exp) != $data['signature']) {
+            return false;
+        }
+        return true;
+    }
+
+    public function getPrefix()
+    {
+        return $this->prefix;
+    }
+
+    /**
+     * Get payment method image.
+     *
+     * @return array
+     */
+    public function getImage()
+    {
+        $url = $this->getConfigData('image');
+
+        return $url ? Storage::url($url) : bagisto_asset('images/cash-on-delivery.png', 'shop');
+    }
+
+    public function isAvailable()
+    {
+        if (! parent::isAvailable()) {
+            return false;
+        }
+        $info = $this->getOrder() ?: Cart::getCart();
+        if ($info && $billingCountry = $info->billing_address->country ?? false) {
+            $allowedCountries = ['AU', 'AT', 'BE', 'CA', 'CZ', 'DK', 'FI', 'FR', 'DE', 'GR', 'IE',
+                'IT', 'NL', 'NO', 'PL', 'PT', 'ES', 'SE', 'CH', 'GB', 'US'
+            ];
+            return in_array($billingCountry, $allowedCountries);
+        }
+        return true;
+    }
+
+    public function getDescription()
+    {
+        $info = $this->getOrder() ?: Cart::getCart();
+        $amount = round($info->grand_total / 4, 2);
+        $symbol = core()->getCurrentCurrency()->symbol;
+        return "4 interest-free payments of {$symbol}{$amount}";
+    }
+}

+ 41 - 0
packages/Longyi/Pay/AwxKlarna/src/Providers/AwxKlarnaServiceProvider.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace Longyi\Pay\AwxKlarna\Providers;
+
+use Illuminate\Support\ServiceProvider;
+
+class AwxKlarnaServiceProvider extends ServiceProvider
+{
+    /**
+     * Register services.
+     */
+    public function register(): void
+    {
+        $this->registerConfig();
+    }
+
+    /**
+     * Bootstrap services.
+     */
+    public function boot(): void
+    {
+        $this->loadRoutesFrom(__DIR__.'/../Http/routes.php');
+        $this->loadViewsFrom(__DIR__ . '/../Resources/views', 'AwxKlarna');
+    }
+
+    /**
+     * Register package config.
+     *
+     * @return void
+     */
+    protected function registerConfig()
+    {
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/payment-methods.php', 'payment_methods'
+        );
+
+        $this->mergeConfigFrom(
+            dirname(__DIR__) . '/Config/system.php', 'core'
+        );
+    }
+}

+ 112 - 0
packages/Longyi/Pay/AwxKlarna/src/Resources/views/checkout/onepage/paypal-smart-button.blade.php

@@ -0,0 +1,112 @@
+@if (
+    request()->routeIs('shop.checkout.onepage.index')
+    && (bool) core()->getConfigData('sales.payment_methods.paypal_smart_button.active')
+)
+    @php
+        $clientId = core()->getConfigData('sales.payment_methods.paypal_smart_button.client_id');
+
+        $acceptedCurrency = core()->getConfigData('sales.payment_methods.paypal_smart_button.accepted_currencies');
+
+        $currentCurrency = core()->getCurrentCurrencyCode();
+
+        $acceptedCurrenciesArray = array_map('trim', explode(',', $acceptedCurrency));
+
+        $currencyToUse = in_array($currentCurrency, $acceptedCurrenciesArray)
+            ? $currentCurrency
+            : $acceptedCurrenciesArray[0];
+    @endphp
+
+    @pushOnce('scripts')
+        <script
+            src="https://www.paypal.com/sdk/js?client-id={{ $clientId }}&currency={{ $currencyToUse }}"
+            data-partner-attribution-id="Bagisto_Cart"
+        >
+        </script>
+
+        <script
+            type="text/x-template"
+            id="v-paypal-smart-button-template"
+        >
+            <div class="w-full paypal-button-container"></div>
+        </script>
+
+        <script type="module">
+            app.component('v-paypal-smart-button', {
+                template: '#v-paypal-smart-button-template',
+
+                mounted() {
+                    this.register();
+                },
+
+                methods: {
+                    register() {
+                        if (typeof paypal == 'undefined') {
+                            this.$emitter.emit('add-flash', { type: 'error', message: '@lang('paypal::app.errors.invalid-configs')' });
+
+                            return;
+                        }
+
+                        paypal.Buttons(this.getOptions()).render('.paypal-button-container');
+                    },
+
+                    getOptions() {
+                        let options = {
+                            style: {
+                                layout: 'vertical',
+                                shape: 'rect',
+                            },
+
+                            authorizationFailed: false,
+
+                            enableStandardCardFields: false,
+
+                            alertBox: (message) => {
+                                this.$emitter.emit('add-flash', { type: 'error', message: message });
+                            },
+
+                            createOrder: (data, actions) => {
+                                return this.$axios.get("{{ route('paypal.smart-button.create-order') }}")
+                                    .then(response => response.data.result)
+                                    .then(order => order.id)
+                                    .catch(error => {
+                                        if (error.response.data.error === 'invalid_client') {
+                                            options.authorizationFailed = true;
+
+                                            options.alertBox('@lang('paypal::app.errors.invalid-configs')');
+                                        }
+
+                                        return error;
+                                    });
+                            },
+
+                            onApprove: (data, actions) => {
+                                this.$axios.post("{{ route('paypal.smart-button.capture-order') }}", {
+                                    _token: "{{ csrf_token() }}",
+                                    orderData: data
+                                })
+                                .then(response => {
+                                    if (response.data.success) {
+                                        if (response.data.redirect_url) {
+                                            window.location.href = response.data.redirect_url;
+                                        } else {
+                                            window.location.href = "{{ route('shop.checkout.onepage.success') }}";
+                                        }
+                                    }
+                                })
+                                .catch(error => window.location.href = "{{ route('shop.checkout.cart.index') }}");
+                            },
+
+                            onError: (error) => {
+                                if (! options.authorizationFailed) {
+                                    options.alertBox('@lang('paypal::app.errors.something-went-wrong')');
+                                }
+                            },
+                        };
+
+                        return options;
+                    },
+                },
+            });
+        </script>
+    @endPushOnce
+@endif

+ 16 - 0
packages/Longyi/Pay/AwxKlarna/src/Resources/views/standard-redirect.blade.php

@@ -0,0 +1,16 @@
+<?php $awxKlarnaStandard = app('Longyi\Pay\AwxKlarna\Payment\AwxKlarna') ?>
+
+<body data-gr-c-s-loaded="true" cz-shortcut-listen="true">
+    You will be redirected to the Klarna website in a few seconds.
+
+
+    <form action="{{ $awxKlarnaStandard->getAwxKlarnaUrl() }}" id="klarna_standard_checkout" method="POST">
+        <input value="Click here if you are not redirected within 10 seconds..." type="submit">
+        <input type="hidden" name="_token" value="{{ csrf_token() }}">
+        <input type="hidden" name="orderId" value="{{ $order->id }}">
+    </form>
+
+    <script type="text/javascript">
+        document.getElementById("klarna_standard_checkout").submit();
+    </script>
+</body>

+ 20 - 0
packages/Webkul/Admin/src/Config/acl.php

@@ -855,6 +855,26 @@ return [
         'name'  => 'admin::app.acl.import',
         'route' => 'admin.settings.data_transfer.imports.import',
         'sort'  => 4,
+    ], [
+        'key'   => 'settings.shipping_zone_rates',
+        'name'  => 'admin::app.components.layouts.sidebar.shipping-zone-rates',
+        'route' => 'admin.settings.shipping_zone_rates.index',
+        'sort'  => 11,
+    ], [
+        'key'   => 'settings.shipping_zone_rates.create',
+        'name'  => 'admin::app.acl.create',
+        'route' => 'admin.settings.shipping_zone_rates.store',
+        'sort'  => 1,
+    ], [
+        'key'   => 'settings.shipping_zone_rates.edit',
+        'name'  => 'admin::app.acl.edit',
+        'route' => 'admin.settings.shipping_zone_rates.edit',
+        'sort'  => 2,
+    ], [
+        'key'   => 'settings.shipping_zone_rates.delete',
+        'name'  => 'admin::app.acl.delete',
+        'route' => 'admin.settings.shipping_zone_rates.delete',
+        'sort'  => 3,
     ],
 
     /*

+ 6 - 0
packages/Webkul/Admin/src/Config/menu.php

@@ -348,6 +348,12 @@ return [
         'route'      => 'admin.settings.data_transfer.imports.index',
         'sort'       => 1,
         'icon'       => '',
+    ], [
+        'key'        => 'settings.shipping_zone_rates',
+        'name'       => 'admin::app.components.layouts.sidebar.shipping-zone-rates',
+        'route'      => 'admin.settings.shipping_zone_rates.index',
+        'sort'       => 11,
+        'icon'       => '',
     ],
 
     /**

+ 169 - 0
packages/Webkul/Admin/src/DataGrids/Settings/ShippingZoneRatesDataGrid.php

@@ -0,0 +1,169 @@
+<?php
+
+namespace Webkul\Admin\DataGrids\Settings;
+
+use Illuminate\Support\Facades\DB;
+use Webkul\DataGrid\DataGrid;
+
+class ShippingZoneRatesDataGrid extends DataGrid
+{
+    /**
+     * Primary column.
+     *
+     * @var string
+     */
+    protected $primaryColumn = 'shipping_zone_rate_id';
+
+    /**
+     * Prepare query builder.
+     *
+     * @return \Illuminate\Database\Query\Builder
+     */
+    public function prepareQueryBuilder()
+    {
+        $queryBuilder = DB::table('shipping_zone_rates')
+            ->select(
+                'shipping_zone_rates.id as shipping_zone_rate_id',
+                'shipping_zone_rates.carrier_code',
+                'shipping_zone_rates.carrier_title',
+                'shipping_zone_rates.country_code',
+                'shipping_zone_rates.state_code',
+                'shipping_zone_rates.order_subtotal_from',
+                'shipping_zone_rates.order_subtotal_to',
+                'shipping_zone_rates.price',
+                'shipping_zone_rates.delivery_type',
+                'shipping_zone_rates.delivery_description',
+                'shipping_zone_rates.is_active',
+                'shipping_zone_rates.sort_order',
+            );
+
+        $this->addFilter('shipping_zone_rate_id', 'shipping_zone_rates.id');
+        $this->addFilter('carrier_code', 'shipping_zone_rates.carrier_code');
+        $this->addFilter('carrier_title', 'shipping_zone_rates.carrier_title');
+        $this->addFilter('country_code', 'shipping_zone_rates.country_code');
+        $this->addFilter('state_code', 'shipping_zone_rates.state_code');
+        $this->addFilter('delivery_type', 'shipping_zone_rates.delivery_type');
+        $this->addFilter('price', 'shipping_zone_rates.price');
+
+        return $queryBuilder;
+    }
+
+    public function prepareColumns()
+    {
+        $this->addColumn([
+            'index'      => 'shipping_zone_rate_id',
+            'label'      => trans('admin::app.settings.shipping_zone_rates.index.datagrid.id'),
+            'type'       => 'integer',
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'carrier_title',
+            'label'      => trans('admin::app.settings.shipping_zone_rates.index.datagrid.carrier-title'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'carrier_code',
+            'label'      => trans('admin::app.settings.shipping_zone_rates.index.datagrid.carrier-code'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'country_code',
+            'label'      => trans('admin::app.settings.shipping_zone_rates.index.datagrid.country-code'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'state_code',
+            'label'      => trans('admin::app.settings.shipping_zone_rates.index.datagrid.state-code'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'delivery_description',
+            'label'      => trans('admin::app.settings.shipping_zone_rates.index.datagrid.delivery-description'),
+            'type'       => 'string',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'order_subtotal_from',
+            'label'      => trans('admin::app.settings.shipping_zone_rates.index.datagrid.subtotal-from'),
+            'type'       => 'decimal',
+            'searchable' => false,
+            'filterable' => false,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'order_subtotal_to',
+            'label'      => trans('admin::app.settings.shipping_zone_rates.index.datagrid.subtotal-to'),
+            'type'       => 'decimal',
+            'searchable' => false,
+            'filterable' => false,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'price',
+            'label'      => trans('admin::app.settings.shipping_zone_rates.index.datagrid.price'),
+            'type'       => 'decimal',
+            'searchable' => true,
+            'filterable' => true,
+            'sortable'   => true,
+        ]);
+
+        $this->addColumn([
+            'index'      => 'sort_order',
+            'label'      => trans('admin::app.settings.shipping_zone_rates.index.datagrid.sort-order'),
+            'type'       => 'integer',
+            'searchable' => false,
+            'filterable' => false,
+            'sortable'   => true,
+        ]);
+    }
+
+    public function prepareActions()
+    {
+        if (bouncer()->hasPermission('settings.shipping_zone_rates.edit')) {
+            $this->addAction([
+                'index'  => 'edit',
+                'icon'   => 'icon-edit',
+                'title'  => trans('admin::app.settings.shipping_zone_rates.index.datagrid.edit'),
+                'method' => 'GET',
+                'url'    => function ($row) {
+                    return route('admin.settings.shipping_zone_rates.edit', $row->shipping_zone_rate_id);
+                },
+            ]);
+        }
+
+        if (bouncer()->hasPermission('settings.shipping_zone_rates.delete')) {
+            $this->addAction([
+                'index'  => 'delete',
+                'icon'   => 'icon-delete',
+                'title'  => trans('admin::app.settings.shipping_zone_rates.index.datagrid.delete'),
+                'method' => 'DELETE',
+                'url'    => function ($row) {
+                    return route('admin.settings.shipping_zone_rates.delete', $row->shipping_zone_rate_id);
+                },
+            ]);
+        }
+    }
+}

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

@@ -92,7 +92,7 @@ class ProductController extends Controller
         $validationRules = [
             'type' => 'required',
             'attribute_family_id' => 'required',
-            'sku' => ['required', 'unique:products,sku', new Slug],
+            'sku' => ['required', 'unique:products,sku'],
         ];
         if ($productType !== 'flexible_variant') {
             $validationRules['super_attributes'] = 'array|min:1';

+ 207 - 0
packages/Webkul/Admin/src/Http/Controllers/Settings/ShippingZoneRateController.php

@@ -0,0 +1,207 @@
+<?php
+
+namespace Webkul\Admin\Http\Controllers\Settings;
+
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Webkul\Admin\DataGrids\Settings\ShippingZoneRatesDataGrid;
+use Webkul\Admin\Http\Controllers\Controller;
+use Webkul\Shipping\Models\ShippingZoneRate;
+
+class ShippingZoneRateController extends Controller
+{
+    /**
+     * Display a listing of the resource.
+     *
+     * @return \Illuminate\View\View
+     */
+    public function index()
+    {
+        if (request()->ajax()) {
+            return datagrid(ShippingZoneRatesDataGrid::class)->process();
+        }
+
+        return view('admin::settings.shipping-zone-rates.index');
+    }
+
+    /**
+     * Store a newly created resource in storage.
+     */
+    public function store(Request $request): JsonResponse
+    {
+        $this->validate($request, [
+            'carrier_code'         => 'required|string|max:255',
+            'carrier_title'        => 'required|string|max:255',
+            'country_code'         => 'nullable|string|max:10',
+            'state_code'           => 'nullable|string|max:10',
+            'order_subtotal_from'  => 'nullable|numeric',
+            'order_subtotal_to'    => 'nullable|numeric',
+            'price'                => 'required|numeric',
+            'delivery_type'        => 'required|string|max:255',
+            'delivery_description' => 'nullable|string|max:255',
+            'is_active'            => 'nullable|boolean',
+            'sort_order'           => 'nullable|integer',
+        ]);
+
+        ShippingZoneRate::create($request->only([
+            'carrier_code',
+            'carrier_title',
+            'country_code',
+            'state_code',
+            'order_subtotal_from',
+            'order_subtotal_to',
+            'price',
+            'delivery_type',
+            'delivery_description',
+            'is_active',
+            'sort_order',
+        ]));
+
+        return new JsonResponse([
+            'message' => trans('admin::app.settings.shipping_zone_rates.index.create-success'),
+        ]);
+    }
+
+    /**
+     * Show the form for editing the specified resource.
+     */
+    public function edit(int $id): JsonResponse
+    {
+        $rate = ShippingZoneRate::findOrFail($id);
+
+        return new JsonResponse([
+            'data' => $rate->toArray(),
+        ]);
+    }
+
+    /**
+     * Update the specified resource in storage.
+     */
+    public function update(Request $request): JsonResponse
+    {
+        $this->validate($request, [
+            'carrier_code'         => 'required|string|max:255',
+            'carrier_title'        => 'required|string|max:255',
+            'country_code'         => 'nullable|string|max:10',
+            'state_code'           => 'nullable|string|max:10',
+            'order_subtotal_from'  => 'nullable|numeric',
+            'order_subtotal_to'    => 'nullable|numeric',
+            'price'                => 'required|numeric',
+            'delivery_type'        => 'required|string|max:255',
+            'delivery_description' => 'nullable|string|max:255',
+            'is_active'            => 'nullable|boolean',
+            'sort_order'           => 'nullable|integer',
+        ]);
+
+        $rate = ShippingZoneRate::findOrFail($request->id);
+
+        $rate->update($request->only([
+            'carrier_code',
+            'carrier_title',
+            'country_code',
+            'state_code',
+            'order_subtotal_from',
+            'order_subtotal_to',
+            'price',
+            'delivery_type',
+            'delivery_description',
+            'is_active',
+            'sort_order',
+        ]));
+
+        return new JsonResponse([
+            'message' => trans('admin::app.settings.shipping_zone_rates.index.update-success'),
+        ]);
+    }
+
+    /**
+     * Remove the specified resource from storage.
+     */
+    public function destroy(int $id): JsonResponse
+    {
+        try {
+            ShippingZoneRate::findOrFail($id)->delete();
+
+            return new JsonResponse([
+                'message' => trans('admin::app.settings.shipping_zone_rates.index.delete-success'),
+            ], 200);
+        } catch (\Exception $e) {
+            report($e);
+        }
+
+        return new JsonResponse([
+            'message' => trans('admin::app.settings.shipping_zone_rates.index.delete-error'),
+        ], 500);
+    }
+
+    /**
+     * Mass delete resources.
+     */
+    public function massDestroy(Request $request): JsonResponse
+    {
+        $request->validate([
+            'indices' => 'required|array',
+        ]);
+
+        try {
+            ShippingZoneRate::whereIn('id', $request->indices)->delete();
+
+            return new JsonResponse([
+                'message' => trans('admin::app.settings.shipping_zone_rates.index.delete-success'),
+            ]);
+        } catch (\Exception $e) {
+            report($e);
+        }
+
+        return new JsonResponse([
+            'message' => trans('admin::app.settings.shipping_zone_rates.index.delete-error'),
+        ], 500);
+    }
+
+    /**
+     * Import CSV file.
+     */
+    public function import(Request $request): JsonResponse
+    {
+        $request->validate([
+            'csv_file'   => 'required|file|mimes:csv,txt',
+            'truncate'   => 'nullable|boolean',
+        ]);
+
+        $file = $request->file('csv_file');
+        $filePath = $file->getRealPath();
+        $truncate = $request->boolean('truncate', false);
+
+        try {
+            $result = ShippingZoneRate::importFromCsv($filePath, $truncate);
+
+            if ($truncate) {
+                $message = trans('admin::app.settings.shipping_zone_rates.index.import-truncate-success', [
+                    'count' => $result['inserted'],
+                ]);
+            } else {
+                $message = trans('admin::app.settings.shipping_zone_rates.index.import-success', [
+                    'count' => $result['inserted'],
+                ]);
+            }
+
+            if (! empty($result['errors'])) {
+                $errorCount = count($result['errors']);
+                $message .= ' ' . trans('admin::app.settings.shipping_zone_rates.index.import-errors', [
+                    'count' => $errorCount,
+                ]);
+            }
+
+            return new JsonResponse([
+                'message'  => $message,
+                'total'    => $result['total'],
+                'inserted' => $result['inserted'],
+                'errors'   => $result['errors'],
+            ]);
+        } catch (\Exception $e) {
+            return new JsonResponse([
+                'message' => $e->getMessage(),
+            ], 422);
+        }
+    }
+}

+ 1 - 1
packages/Webkul/Admin/src/Http/Requests/ProductForm.php

@@ -73,7 +73,7 @@ class ProductForm extends FormRequest
         $this->product = $this->productRepository->find($this->id);
 
         $this->rules = array_merge($this->product->getTypeInstance()->getTypeValidationRules(), [
-            'sku'                  => ['required', 'unique:products,sku,'.$this->id, new Slug],
+            'sku'                  => ['required', 'unique:products,sku,'.$this->id],
             'url_key'              => ['required', new ProductCategoryUniqueSlug('products', $this->id)],
             'images.files.*'       => ['nullable', 'mimes:bmp,jpeg,jpg,png,webp,gif'],
             'images.positions.*'   => ['nullable', 'integer'],

+ 73 - 0
packages/Webkul/Admin/src/Resources/lang/en/app.php

@@ -3537,6 +3537,68 @@ return [
             'delete-success' => 'Theme deleted successfully',
             'update-success' => 'Theme updated successfully',
         ],
+
+        'shipping_zone_rates' => [
+            'index' => [
+                'title'           => 'Shipping Zone Rates',
+                'create-btn'      => 'Create Rate',
+                'import-btn'      => 'Import CSV',
+                'create-success'  => 'Rate created successfully.',
+                'update-success'  => 'Rate updated successfully.',
+                'delete-success'  => 'Rate deleted successfully.',
+                'delete-error'    => 'Error deleting rate.',
+                'import-success'  => 'Successfully imported :count rate records.',
+                'import-truncate-success' => 'Table truncated, successfully imported :count rate records.',
+                'import-errors'   => ', but :count records failed to import.',
+                'yes'             => 'Yes',
+                'no'              => 'No',
+                'cancel'          => 'Cancel',
+
+                'create' => [
+                    'title'                => 'Create Rate',
+                    'carrier-code'         => 'Carrier Code',
+                    'carrier-title'        => 'Carrier Title',
+                    'country-code'         => 'Country Code',
+                    'state-code'           => 'State Code',
+                    'subtotal-from'        => 'Order Subtotal From',
+                    'subtotal-to'          => 'Order Subtotal To',
+                    'price'                => 'Shipping Price',
+                    'delivery-type'        => 'Delivery Type',
+                    'delivery-description' => 'Delivery Description',
+                    'is-active'            => 'Is Active',
+                    'sort-order'           => 'Sort Order',
+                    'save-btn'             => 'Save',
+                ],
+
+                'edit' => [
+                    'title' => 'Edit Rate',
+                ],
+
+                'import' => [
+                    'title'          => 'Import from CSV',
+                    'description'    => 'CSV columns: Country, Region/State, City, Zip/Postal Code From, Zip/Postal Code To, Order Subtotal From, Order Subtotal To, Shipping Price, Delivery Type',
+                    'file'           => 'CSV File',
+                    'upload-btn'     => 'Upload & Import',
+                    'truncate'       => 'Truncate existing data before import',
+                    'truncate-label' => 'Truncate Option',
+                ],
+
+                'datagrid' => [
+                    'id'                   => 'ID',
+                    'carrier-code'         => 'Carrier Code',
+                    'carrier-title'        => 'Carrier Title',
+                    'country-code'         => 'Country Code',
+                    'state-code'           => 'State Code',
+                    'delivery-description' => 'Delivery Description',
+                    'subtotal-from'        => 'Subtotal From',
+                    'subtotal-to'          => 'Subtotal To',
+                    'price'                => 'Price',
+                    'sort-order'           => 'Sort Order',
+                    'edit'                 => 'Edit',
+                    'delete'               => 'Delete',
+                ],
+            ],
+        ],
     ],
 
     'reporting' => [
@@ -4391,6 +4453,15 @@ return [
                             'title'     => 'Type',
                         ],
                     ],
+
+                    'zone-rate' => [
+                        'allowed-countries' => 'Allowed Countries',
+                        'description'       => 'Description',
+                        'page-title'        => 'Zone Rate',
+                        'status'            => 'Status',
+                        'title'             => 'Title',
+                        'title-info'        => 'Zone-based shipping rates that vary by destination country/region and order subtotal. Configure specific rates via the Shipping Zone Rates table.',
+                    ],
                 ],
 
                 'payment-methods' => [
@@ -4673,6 +4744,7 @@ return [
                 'search-terms'             => 'Search Terms',
                 'settings'                 => 'Settings',
                 'shipments'                => 'Shipments',
+                'shipping-zone-rates'      => 'Shipping Zone Rates',
                 'sitemaps'                 => 'Sitemaps',
                 'tax-categories'           => 'Tax Categories',
                 'tax-rates'                => 'Tax Rates',
@@ -4936,6 +5008,7 @@ return [
         'search-terms'             => 'Search Terms',
         'settings'                 => 'Settings',
         'shipments'                => 'Shipments',
+        'shipping-zone-rates'      => 'Shipping Zone Rates',
         'sitemaps'                 => 'Sitemaps',
         'subscribers'              => 'Newsletter Subscribers',
         'tax-categories'           => 'Tax Categories',

+ 73 - 0
packages/Webkul/Admin/src/Resources/lang/zh_CN/app.php

@@ -3537,6 +3537,68 @@ return [
             'delete-success' => '成功删除主题',
             'update-success' => '成功更新主题',
         ],
+
+        'shipping_zone_rates' => [
+            'index' => [
+                'title'           => '运输区域费率',
+                'create-btn'      => '创建费率',
+                'import-btn'      => '导入CSV',
+                'create-success'  => '费率创建成功。',
+                'update-success'  => '费率更新成功。',
+                'delete-success'  => '费率删除成功。',
+                'delete-error'    => '删除费率时出错。',
+                'import-success'  => '成功导入 :count 条费率记录。',
+                'import-truncate-success' => '表已清空,成功导入 :count 条费率记录。',
+                'import-errors'   => ',但有 :count 条记录导入失败。',
+                'yes'             => '是',
+                'no'              => '否',
+                'cancel'          => '取消',
+
+                'create' => [
+                    'title'                => '创建费率',
+                    'carrier-code'         => '运输商代码',
+                    'carrier-title'        => '运输商名称',
+                    'country-code'         => '国家代码',
+                    'state-code'           => '州/省代码',
+                    'subtotal-from'        => '订单金额从',
+                    'subtotal-to'          => '订单金额到',
+                    'price'                => '运费价格',
+                    'delivery-type'        => '运输类型',
+                    'delivery-description' => '运输描述',
+                    'is-active'            => '是否启用',
+                    'sort-order'           => '排序',
+                    'save-btn'             => '保存',
+                ],
+
+                'edit' => [
+                    'title' => '编辑费率',
+                ],
+
+                'import' => [
+                    'title'          => '从CSV导入',
+                    'description'    => 'CSV文件列顺序:Country, Region/State, City, Zip/Postal Code From, Zip/Postal Code To, Order Subtotal From, Order Subtotal To, Shipping Price, Delivery Type',
+                    'file'           => 'CSV文件',
+                    'upload-btn'     => '上传并导入',
+                    'truncate'       => '导入前清空现有数据',
+                    'truncate-label' => '清空选项',
+                ],
+
+                'datagrid' => [
+                    'id'                   => 'ID',
+                    'carrier-code'         => '运输商代码',
+                    'carrier-title'        => '运输商名称',
+                    'country-code'         => '国家代码',
+                    'state-code'           => '州/省代码',
+                    'delivery-description' => '运输描述',
+                    'subtotal-from'        => '金额从',
+                    'subtotal-to'          => '金额到',
+                    'price'                => '运费',
+                    'sort-order'           => '排序',
+                    'edit'                 => '编辑',
+                    'delete'               => '删除',
+                ],
+            ],
+        ],
     ],
 
     'reporting' => [
@@ -4391,6 +4453,15 @@ return [
                             'title'     => '类型',
                         ],
                     ],
+
+                    'zone-rate' => [
+                        'allowed-countries' => '允许的国家',
+                        'description'       => '描述',
+                        'page-title'        => '区域费率',
+                        'status'            => '状态',
+                        'title'             => '标题',
+                        'title-info'        => '按目的地国家/地区和订单金额区间计算不同的运费。具体费率请在配送区域费率表中配置。',
+                    ],
                 ],
 
                 'payment-methods' => [
@@ -4673,6 +4744,7 @@ return [
                 'search-terms'             => '搜索词',
                 'settings'                 => '设置',
                 'shipments'                => '发货',
+                'shipping-zone-rates'      => '运输区域费率',
                 'sitemaps'                 => '站点地图',
                 'tax-categories'           => '税收分类',
                 'tax-rates'                => '税率',
@@ -4936,6 +5008,7 @@ return [
         'search-terms'             => '搜索词',
         'settings'                 => '设置',
         'shipments'                => '发货',
+        'shipping-zone-rates'      => '运输区域费率',
         'sitemaps'                 => '网站地图',
         'subscribers'              => '电子报订阅者',
         'tax-categories'           => '税务类别',

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

@@ -161,9 +161,12 @@
                         @php $customAttributes = $product->getEditableAttributes($group); @endphp
 
                         @if (
-                            $group->code === 'inventories' 
+                            $group->code === 'inventories'
                             && (
-                                $product->getTypeInstance()->isComposite()
+                                (
+                                    $product->getTypeInstance()->isComposite()
+                                    && $product->type !== 'flexible_variant'
+                                )
                                 || $product->type === 'downloadable'
                             )
                         )

+ 0 - 1
packages/Webkul/Admin/src/Resources/views/catalog/products/edit/inventories.blade.php

@@ -1,5 +1,4 @@
 {!! view_render_event('bagisto.admin.catalog.product.edit.form.inventories.controls.before', ['product' => $product]) !!}
-
 <v-inventories>
     <!-- Panel Content -->
     <div class="mb-5 text-sm text-gray-600 dark:text-gray-300">

+ 2 - 0
packages/Webkul/Admin/src/Resources/views/components/button/index.blade.php

@@ -7,6 +7,7 @@
     >
         <button
             v-if="! loading"
+            :type="buttonType ?? 'button'"
             :class="[buttonClass, '']"
         >
             @{{ title }}
@@ -14,6 +15,7 @@
 
         <button
             v-else
+            :type="buttonType ?? 'button'"
             :class="[buttonClass, '']"
         >
             <!-- Spinner -->

+ 945 - 0
packages/Webkul/Admin/src/Resources/views/settings/shipping-zone-rates/index.blade.php

@@ -0,0 +1,945 @@
+<x-admin::layouts>
+    <x-slot:title>
+        @lang('admin::app.settings.shipping_zone_rates.index.title')
+    </x-slot>
+
+    {!! view_render_event('bagisto.admin.settings.shipping_zone_rates.create.before') !!}
+
+    <v-shipping-zone-rates>
+        <div class="flex items-center justify-between">
+            <p class="text-xl font-bold text-gray-800 dark:text-white">
+                @lang('admin::app.settings.shipping_zone_rates.index.title')
+            </p>
+
+            <div class="flex items-center gap-x-2.5">
+                <!-- Import CSV Button -->
+                @if (bouncer()->hasPermission('settings.shipping_zone_rates.create'))
+                    <button
+                        type="button"
+                        class="secondary-button"
+                        @click="$refs.importModal.toggle()"
+                    >
+                        @lang('admin::app.settings.shipping_zone_rates.index.import-btn')
+                    </button>
+                @endif
+
+                <!-- Create Button -->
+                @if (bouncer()->hasPermission('settings.shipping_zone_rates.create'))
+                    <button
+                        type="button"
+                        class="primary-button"
+                        @click="selectedRate=0; resetForm(); $refs.rateUpdateOrCreateModal.toggle()"
+                    >
+                        @lang('admin::app.settings.shipping_zone_rates.index.create-btn')
+                    </button>
+                @endif
+            </div>
+        </div>
+
+        <!-- DataGrid Shimmer -->
+        <x-admin::shimmer.datagrid />
+
+        <x-admin::datagrid
+            :src="route('admin.settings.shipping_zone_rates.index')"
+            ref="datagrid"
+        >
+            <template #body="{
+                isLoading,
+                available,
+                applied,
+                selectAll,
+                sort,
+                performAction
+            }">
+                <template v-if="isLoading">
+                    <x-admin::shimmer.datagrid.table.body />
+                </template>
+
+                <template v-else>
+                    <div
+                        v-for="record in available.records"
+                        class="row grid items-center gap-2.5 border-b px-4 py-4 text-gray-600 transition-all hover:bg-gray-50 dark:border-gray-800 dark:text-gray-300 dark:hover:bg-gray-950"
+                        :style="`grid-template-columns: repeat(${gridsCount}, minmax(0, 1fr))`"
+                    >
+                        <!-- ID -->
+                        <p>@{{ record.shipping_zone_rate_id }}</p>
+
+                        <!-- Carrier Title -->
+                        <p>@{{ record.carrier_title }}</p>
+
+                        <!-- Carrier Code -->
+                        <p>@{{ record.carrier_code }}</p>
+
+                        <!-- Country Code -->
+                        <p>@{{ record.country_code }}</p>
+
+                        <!-- State Code -->
+                        <p>@{{ record.state_code }}</p>
+
+                        <!-- Delivery Description -->
+                        <p>@{{ record.delivery_description }}</p>
+
+                        <!-- Subtotal From -->
+                        <p>@{{ record.order_subtotal_from }}</p>
+
+                        <!-- Subtotal To -->
+                        <p>@{{ record.order_subtotal_to }}</p>
+
+                        <!-- Price -->
+                        <p>@{{ record.price }}</p>
+
+                        <!-- Sort Order -->
+                        <p>@{{ record.sort_order }}</p>
+
+                        <!-- Actions -->
+                        <div class="flex justify-end">
+                            @if (bouncer()->hasPermission('settings.shipping_zone_rates.edit'))
+                                <a @click="selectedRate=1; editModal(record.actions.find(action => action.index === 'edit')?.url)">
+                                    <span
+                                        :class="record.actions.find(action => action.index === 'edit')?.icon"
+                                        class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center"
+                                    >
+                                    </span>
+                                </a>
+                            @endif
+
+                            @if (bouncer()->hasPermission('settings.shipping_zone_rates.delete'))
+                                <a @click="performAction(record.actions.find(action => action.index === 'delete'))">
+                                    <span
+                                        :class="record.actions.find(action => action.index === 'delete')?.icon"
+                                        class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center"
+                                    >
+                                    </span>
+                                </a>
+                            @endif
+                        </div>
+                    </div>
+                </template>
+            </template>
+        </x-admin::datagrid>
+
+        <!-- Create / Edit Modal -->
+        <x-admin::form
+            v-slot="{ meta, errors, handleSubmit }"
+            as="div"
+            ref="modalForm"
+        >
+            <form
+                @submit="handleSubmit($event, updateOrCreate)"
+                ref="rateCreateForm"
+            >
+                <x-admin::modal ref="rateUpdateOrCreateModal">
+                    <!-- Modal Header -->
+                    <x-slot:header>
+                        <p class="text-lg font-bold text-gray-800 dark:text-white">
+                            <span v-if="selectedRate">
+                                @lang('admin::app.settings.shipping_zone_rates.index.edit.title')
+                            </span>
+
+                            <span v-else>
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.title')
+                            </span>
+                        </p>
+                    </x-slot>
+
+                    <!-- Modal Content -->
+                    <x-slot:content>
+                        <x-admin::form.control-group.control
+                            type="hidden"
+                            name="id"
+                            v-model="selectedRateData.id"
+                        />
+
+                        <!-- Carrier Code -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label class="required">
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.carrier-code')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="text"
+                                name="carrier_code"
+                                rules="required"
+                                v-model="selectedRateData.carrier_code"
+                                :label="trans('admin::app.settings.shipping_zone_rates.index.create.carrier-code')"
+                            />
+
+                            <x-admin::form.control-group.error control-name="carrier_code" />
+                        </x-admin::form.control-group>
+
+                        <!-- Carrier Title -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label class="required">
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.carrier-title')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="text"
+                                name="carrier_title"
+                                rules="required"
+                                v-model="selectedRateData.carrier_title"
+                                :label="trans('admin::app.settings.shipping_zone_rates.index.create.carrier-title')"
+                            />
+
+                            <x-admin::form.control-group.error control-name="carrier_title" />
+                        </x-admin::form.control-group>
+
+                        <!-- Country Code -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label>
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.country-code')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="text"
+                                name="country_code"
+                                v-model="selectedRateData.country_code"
+                                :label="trans('admin::app.settings.shipping_zone_rates.index.create.country-code')"
+                            />
+
+                            <x-admin::form.control-group.error control-name="country_code" />
+                        </x-admin::form.control-group>
+
+                        <!-- State Code -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label>
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.state-code')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="text"
+                                name="state_code"
+                                v-model="selectedRateData.state_code"
+                                :label="trans('admin::app.settings.shipping_zone_rates.index.create.state-code')"
+                            />
+
+                            <x-admin::form.control-group.error control-name="state_code" />
+                        </x-admin::form.control-group>
+
+                        <!-- Order Subtotal From -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label>
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.subtotal-from')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="text"
+                                name="order_subtotal_from"
+                                v-model="selectedRateData.order_subtotal_from"
+                                :label="trans('admin::app.settings.shipping_zone_rates.index.create.subtotal-from')"
+                            />
+
+                            <x-admin::form.control-group.error control-name="order_subtotal_from" />
+                        </x-admin::form.control-group>
+
+                        <!-- Order Subtotal To -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label>
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.subtotal-to')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="text"
+                                name="order_subtotal_to"
+                                v-model="selectedRateData.order_subtotal_to"
+                                :label="trans('admin::app.settings.shipping_zone_rates.index.create.subtotal-to')"
+                            />
+
+                            <x-admin::form.control-group.error control-name="order_subtotal_to" />
+                        </x-admin::form.control-group>
+
+                        <!-- Price -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label class="required">
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.price')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="text"
+                                name="price"
+                                rules="required"
+                                v-model="selectedRateData.price"
+                                :label="trans('admin::app.settings.shipping_zone_rates.index.create.price')"
+                            />
+
+                            <x-admin::form.control-group.error control-name="price" />
+                        </x-admin::form.control-group>
+
+                        <!-- Delivery Type -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label class="required">
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.delivery-type')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="text"
+                                name="delivery_type"
+                                rules="required"
+                                v-model="selectedRateData.delivery_type"
+                                :label="trans('admin::app.settings.shipping_zone_rates.index.create.delivery-type')"
+                            />
+
+                            <x-admin::form.control-group.error control-name="delivery_type" />
+                        </x-admin::form.control-group>
+
+                        <!-- Delivery Description -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label>
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.delivery-description')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="text"
+                                name="delivery_description"
+                                v-model="selectedRateData.delivery_description"
+                                :label="trans('admin::app.settings.shipping_zone_rates.index.create.delivery-description')"
+                            />
+
+                            <x-admin::form.control-group.error control-name="delivery_description" />
+                        </x-admin::form.control-group>
+
+                        <!-- Is Active -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label>
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.is-active')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="select"
+                                name="is_active"
+                                v-model="selectedRateData.is_active"
+                            >
+                                <option value="1">@lang('admin::app.settings.shipping_zone_rates.index.yes')</option>
+                                <option value="0">@lang('admin::app.settings.shipping_zone_rates.index.no')</option>
+                            </x-admin::form.control-group.control>
+
+                            <x-admin::form.control-group.error control-name="is_active" />
+                        </x-admin::form.control-group>
+
+                        <!-- Sort Order -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label>
+                                @lang('admin::app.settings.shipping_zone_rates.index.create.sort-order')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="text"
+                                name="sort_order"
+                                v-model="selectedRateData.sort_order"
+                                :label="trans('admin::app.settings.shipping_zone_rates.index.create.sort-order')"
+                            />
+
+                            <x-admin::form.control-group.error control-name="sort_order" />
+                        </x-admin::form.control-group>
+                    </x-slot>
+
+                    <!-- Modal Footer -->
+                    <x-slot:footer>
+                        <x-admin::button
+                            button-type="button"
+                            class="secondary-button mr-2"
+                            :title="trans('admin::app.settings.shipping_zone_rates.index.cancel')"
+                            @click="$refs.rateUpdateOrCreateModal.close()"
+                        />
+
+                        <x-admin::button
+                            button-type="submit"
+                            class="primary-button"
+                            :title="trans('admin::app.settings.shipping_zone_rates.index.create.save-btn')"
+                            ::loading="isLoading"
+                            ::disabled="isLoading"
+                        />
+                    </x-slot>
+                </x-admin::modal>
+            </form>
+        </x-admin::form>
+
+        <!-- Import CSV Modal -->
+        <x-admin::form
+            v-slot="{ meta, errors, handleSubmit }"
+            as="div"
+            ref="importModalForm"
+        >
+            <form
+                @submit="handleSubmit($event, importCsv)"
+                ref="importForm"
+            >
+                <x-admin::modal ref="importModal">
+                    <!-- Modal Header -->
+                    <x-slot:header>
+                        <p class="text-lg font-bold text-gray-800 dark:text-white">
+                            @lang('admin::app.settings.shipping_zone_rates.index.import.title')
+                        </p>
+                    </x-slot>
+
+                    <!-- Modal Content -->
+                    <x-slot:content>
+                        <div class="mb-4 text-sm text-gray-600 dark:text-gray-300">
+                            @lang('admin::app.settings.shipping_zone_rates.index.import.description')
+                        </div>
+
+                        <!-- CSV File -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label class="required">
+                                @lang('admin::app.settings.shipping_zone_rates.index.import.file')
+                            </x-admin::form.control-group.label>
+
+                            <x-admin::form.control-group.control
+                                type="file"
+                                name="csv_file"
+                                rules="required"
+                                accept=".csv"
+                            />
+
+                            <x-admin::form.control-group.error control-name="csv_file" />
+                        </x-admin::form.control-group>
+
+                        <!-- Truncate Checkbox -->
+                        <x-admin::form.control-group>
+                            <x-admin::form.control-group.label>
+                                @lang('admin::app.settings.shipping_zone_rates.index.import.truncate-label')
+                            </x-admin::form.control-group.label>
+
+                            <label class="inline-flex items-center gap-2 cursor-pointer">
+                                <input
+                                    type="checkbox"
+                                    name="truncate"
+                                    value="1"
+                                    class="peer hidden"
+                                >
+                                <span class="flex h-5 w-5 items-center justify-center rounded-md border-2 border-gray-300 transition-all peer-checked:border-blue-600 peer-checked:bg-blue-600 dark:border-gray-700">
+                                    <span class="icon-tick text-sm text-white opacity-0 peer-checked:opacity-100"></span>
+                                </span>
+                                <span class="text-sm text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.settings.shipping_zone_rates.index.import.truncate')
+                                </span>
+                            </label>
+                        </x-admin::form.control-group>
+                    </x-slot>
+
+                    <!-- Modal Footer -->
+                    <x-slot:footer>
+                        <x-admin::button
+                            button-type="button"
+                            class="secondary-button mr-2"
+                            :title="trans('admin::app.settings.shipping_zone_rates.index.cancel')"
+                            @click="$refs.importModal.close()"
+                        />
+
+                        <x-admin::button
+                            button-type="submit"
+                            class="primary-button"
+                            :title="trans('admin::app.settings.shipping_zone_rates.index.import.upload-btn')"
+                            ::loading="importLoading"
+                            ::disabled="importLoading"
+                        />
+                    </x-slot>
+                </x-admin::modal>
+            </form>
+        </x-admin::form>
+    </v-shipping-zone-rates>
+
+    {!! view_render_event('bagisto.admin.settings.shipping_zone_rates.create.after') !!}
+
+    @pushOnce('scripts')
+        <script
+            type="text/x-template"
+            id="v-shipping-zone-rates-template"
+        >
+            <div>
+                <div class="flex items-center justify-between">
+                    <p class="text-xl font-bold text-gray-800 dark:text-white">
+                        @lang('admin::app.settings.shipping_zone_rates.index.title')
+                    </p>
+
+                    <div class="flex items-center gap-x-2.5">
+                        <!-- Import CSV Button -->
+                        @if (bouncer()->hasPermission('settings.shipping_zone_rates.create'))
+                            <button
+                                type="button"
+                                class="secondary-button"
+                                @click="$refs.importModal.toggle()"
+                            >
+                                @lang('admin::app.settings.shipping_zone_rates.index.import-btn')
+                            </button>
+                        @endif
+
+                        <!-- Create Button -->
+                        @if (bouncer()->hasPermission('settings.shipping_zone_rates.create'))
+                            <button
+                                type="button"
+                                class="primary-button"
+                                @click="selectedRate=0; resetForm(); $refs.rateUpdateOrCreateModal.toggle()"
+                            >
+                                @lang('admin::app.settings.shipping_zone_rates.index.create-btn')
+                            </button>
+                        @endif
+                    </div>
+                </div>
+
+                <x-admin::datagrid
+                    :src="route('admin.settings.shipping_zone_rates.index')"
+                    ref="datagrid"
+                >
+                    <template #body="{
+                        isLoading,
+                        available,
+                        applied,
+                        selectAll,
+                        sort,
+                        performAction
+                    }">
+                        <template v-if="isLoading">
+                            <x-admin::shimmer.datagrid.table.body />
+                        </template>
+
+                        <template v-else>
+                            <div
+                                v-for="record in available.records"
+                                class="row grid items-center gap-2.5 border-b px-4 py-4 text-gray-600 transition-all hover:bg-gray-50 dark:border-gray-800 dark:text-gray-300 dark:hover:bg-gray-950"
+                                :style="`grid-template-columns: repeat(${gridsCount}, minmax(0, 1fr))`"
+                            >
+                                <!-- ID -->
+                                <p>@{{ record.shipping_zone_rate_id }}</p>
+
+                                <!-- Carrier Title -->
+                                <p>@{{ record.carrier_title }}</p>
+
+                                <!-- Carrier Code -->
+                                <p>@{{ record.carrier_code }}</p>
+
+                                <!-- Country Code -->
+                                <p>@{{ record.country_code }}</p>
+
+                                <!-- State Code -->
+                                <p>@{{ record.state_code }}</p>
+
+                                <!-- Delivery Description -->
+                                <p>@{{ record.delivery_description }}</p>
+
+                                <!-- Subtotal From -->
+                                <p>@{{ record.order_subtotal_from }}</p>
+
+                                <!-- Subtotal To -->
+                                <p>@{{ record.order_subtotal_to }}</p>
+
+                                <!-- Price -->
+                                <p>@{{ record.price }}</p>
+
+                                <!-- Sort Order -->
+                                <p>@{{ record.sort_order }}</p>
+
+                                <!-- Actions -->
+                                <div class="flex justify-end">
+                                    @if (bouncer()->hasPermission('settings.shipping_zone_rates.edit'))
+                                        <a @click="selectedRate=1; editModal(record.actions.find(action => action.index === 'edit')?.url)">
+                                            <span
+                                                :class="record.actions.find(action => action.index === 'edit')?.icon"
+                                                class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center"
+                                            >
+                                            </span>
+                                        </a>
+                                    @endif
+
+                                    @if (bouncer()->hasPermission('settings.shipping_zone_rates.delete'))
+                                        <a @click="performAction(record.actions.find(action => action.index === 'delete'))">
+                                            <span
+                                                :class="record.actions.find(action => action.index === 'delete')?.icon"
+                                                class="cursor-pointer rounded-md p-1.5 text-2xl transition-all hover:bg-gray-200 dark:hover:bg-gray-800 max-sm:place-self-center"
+                                            >
+                                            </span>
+                                        </a>
+                                    @endif
+                                </div>
+                            </div>
+                        </template>
+                    </template>
+                </x-admin::datagrid>
+
+                <!-- Create / Edit Modal -->
+                <x-admin::form
+                    v-slot="{ meta, errors, handleSubmit }"
+                    as="div"
+                    ref="modalForm"
+                >
+                    <form
+                        @submit="handleSubmit($event, updateOrCreate)"
+                        ref="rateCreateForm"
+                    >
+                        <x-admin::modal ref="rateUpdateOrCreateModal">
+                            <x-slot:header>
+                                <p class="text-lg font-bold text-gray-800 dark:text-white">
+                                    <span v-if="selectedRate">
+                                        @lang('admin::app.settings.shipping_zone_rates.index.edit.title')
+                                    </span>
+                                    <span v-else>
+                                        @lang('admin::app.settings.shipping_zone_rates.index.create.title')
+                                    </span>
+                                </p>
+                            </x-slot>
+
+                            <x-slot:content>
+                                <x-admin::form.control-group.control
+                                    type="hidden"
+                                    name="id"
+                                    v-model="selectedRateData.id"
+                                />
+
+                                <div class="grid grid-cols-2 gap-4">
+                                    <!-- Carrier Code -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label class="required">
+                                            @lang('admin::app.settings.shipping_zone_rates.index.create.carrier-code')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="text"
+                                            name="carrier_code"
+                                            rules="required"
+                                            v-model="selectedRateData.carrier_code"
+                                        />
+                                        <x-admin::form.control-group.error control-name="carrier_code" />
+                                    </x-admin::form.control-group>
+
+                                    <!-- Carrier Title -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label class="required">
+                                            @lang('admin::app.settings.shipping_zone_rates.index.create.carrier-title')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="text"
+                                            name="carrier_title"
+                                            rules="required"
+                                            v-model="selectedRateData.carrier_title"
+                                        />
+                                        <x-admin::form.control-group.error control-name="carrier_title" />
+                                    </x-admin::form.control-group>
+
+                                    <!-- Country Code -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label>
+                                            @lang('admin::app.settings.shipping_zone_rates.index.create.country-code')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="text"
+                                            name="country_code"
+                                            v-model="selectedRateData.country_code"
+                                        />
+                                        <x-admin::form.control-group.error control-name="country_code" />
+                                    </x-admin::form.control-group>
+
+                                    <!-- State Code -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label>
+                                            @lang('admin::app.settings.shipping_zone_rates.index.create.state-code')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="text"
+                                            name="state_code"
+                                            v-model="selectedRateData.state_code"
+                                        />
+                                        <x-admin::form.control-group.error control-name="state_code" />
+                                    </x-admin::form.control-group>
+
+                                    <!-- Order Subtotal From -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label>
+                                            @lang('admin::app.settings.shipping_zone_rates.index.create.subtotal-from')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="text"
+                                            name="order_subtotal_from"
+                                            v-model="selectedRateData.order_subtotal_from"
+                                        />
+                                        <x-admin::form.control-group.error control-name="order_subtotal_from" />
+                                    </x-admin::form.control-group>
+
+                                    <!-- Order Subtotal To -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label>
+                                            @lang('admin::app.settings.shipping_zone_rates.index.create.subtotal-to')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="text"
+                                            name="order_subtotal_to"
+                                            v-model="selectedRateData.order_subtotal_to"
+                                        />
+                                        <x-admin::form.control-group.error control-name="order_subtotal_to" />
+                                    </x-admin::form.control-group>
+
+                                    <!-- Price -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label class="required">
+                                            @lang('admin::app.settings.shipping_zone_rates.index.create.price')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="text"
+                                            name="price"
+                                            rules="required"
+                                            v-model="selectedRateData.price"
+                                        />
+                                        <x-admin::form.control-group.error control-name="price" />
+                                    </x-admin::form.control-group>
+
+                                    <!-- Delivery Type -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label class="required">
+                                            @lang('admin::app.settings.shipping_zone_rates.index.create.delivery-type')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="text"
+                                            name="delivery_type"
+                                            rules="required"
+                                            v-model="selectedRateData.delivery_type"
+                                        />
+                                        <x-admin::form.control-group.error control-name="delivery_type" />
+                                    </x-admin::form.control-group>
+                                </div>
+
+                                <!-- Delivery Description -->
+                                <x-admin::form.control-group>
+                                    <x-admin::form.control-group.label>
+                                        @lang('admin::app.settings.shipping_zone_rates.index.create.delivery-description')
+                                    </x-admin::form.control-group.label>
+                                    <x-admin::form.control-group.control
+                                        type="text"
+                                        name="delivery_description"
+                                        v-model="selectedRateData.delivery_description"
+                                    />
+                                    <x-admin::form.control-group.error control-name="delivery_description" />
+                                </x-admin::form.control-group>
+
+                                <div class="grid grid-cols-2 gap-4">
+                                    <!-- Is Active -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label>
+                                            @lang('admin::app.settings.shipping_zone_rates.index.create.is-active')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="select"
+                                            name="is_active"
+                                            v-model="selectedRateData.is_active"
+                                        >
+                                            <option value="1">@lang('admin::app.settings.shipping_zone_rates.index.yes')</option>
+                                            <option value="0">@lang('admin::app.settings.shipping_zone_rates.index.no')</option>
+                                        </x-admin::form.control-group.control>
+                                        <x-admin::form.control-group.error control-name="is_active" />
+                                    </x-admin::form.control-group>
+
+                                    <!-- Sort Order -->
+                                    <x-admin::form.control-group>
+                                        <x-admin::form.control-group.label>
+                                            @lang('admin::app.settings.shipping_zone_rates.index.create.sort-order')
+                                        </x-admin::form.control-group.label>
+                                        <x-admin::form.control-group.control
+                                            type="text"
+                                            name="sort_order"
+                                            v-model="selectedRateData.sort_order"
+                                        />
+                                        <x-admin::form.control-group.error control-name="sort_order" />
+                                    </x-admin::form.control-group>
+                                </div>
+                            </x-slot>
+
+                            <x-slot:footer>
+                                <x-admin::button
+                                    button-type="button"
+                                    class="secondary-button mr-2"
+                                    :title="trans('admin::app.settings.shipping_zone_rates.index.cancel')"
+                                    @click="$refs.rateUpdateOrCreateModal.close()"
+                                />
+                                <x-admin::button
+                                    button-type="submit"
+                                    class="primary-button"
+                                    :title="trans('admin::app.settings.shipping_zone_rates.index.create.save-btn')"
+                                    ::loading="isLoading"
+                                    ::disabled="isLoading"
+                                />
+                            </x-slot>
+                        </x-admin::modal>
+                    </form>
+                </x-admin::form>
+
+                <!-- Import CSV Modal -->
+                <x-admin::form
+                    v-slot="{ meta, errors, handleSubmit }"
+                    as="div"
+                    ref="importModalForm"
+                >
+                    <form
+                        @submit="handleSubmit($event, importCsv)"
+                        ref="importForm"
+                    >
+                        <x-admin::modal ref="importModal">
+                            <x-slot:header>
+                                <p class="text-lg font-bold text-gray-800 dark:text-white">
+                                    @lang('admin::app.settings.shipping_zone_rates.index.import.title')
+                                </p>
+                            </x-slot>
+
+                            <x-slot:content>
+                                <div class="mb-4 text-sm text-gray-600 dark:text-gray-300">
+                                    @lang('admin::app.settings.shipping_zone_rates.index.import.description')
+                                </div>
+
+                                <x-admin::form.control-group>
+                                    <x-admin::form.control-group.label class="required">
+                                        @lang('admin::app.settings.shipping_zone_rates.index.import.file')
+                                    </x-admin::form.control-group.label>
+                                    <x-admin::form.control-group.control
+                                        type="file"
+                                        name="csv_file"
+                                        rules="required"
+                                        accept=".csv"
+                                    />
+                                    <x-admin::form.control-group.error control-name="csv_file" />
+                                </x-admin::form.control-group>
+
+                                <x-admin::form.control-group>
+                                    <label class="inline-flex items-center gap-2 cursor-pointer">
+                                        <input
+                                            type="checkbox"
+                                            name="truncate"
+                                            value="1"
+                                            class="peer hidden"
+                                        >
+                                        <span class="flex h-5 w-5 items-center justify-center rounded-md border-2 border-gray-300 transition-all peer-checked:border-blue-600 peer-checked:bg-blue-600 dark:border-gray-700">
+                                            <span class="icon-tick text-sm text-white opacity-0 peer-checked:opacity-100"></span>
+                                        </span>
+                                        <span class="text-sm text-gray-600 dark:text-gray-300">
+                                            @lang('admin::app.settings.shipping_zone_rates.index.import.truncate')
+                                        </span>
+                                    </label>
+                                </x-admin::form.control-group>
+                            </x-slot>
+
+                            <x-slot:footer>
+                                <x-admin::button
+                                    button-type="button"
+                                    class="secondary-button mr-2"
+                                    :title="trans('admin::app.settings.shipping_zone_rates.index.cancel')"
+                                    @click="$refs.importModal.close()"
+                                />
+                                <x-admin::button
+                                    button-type="submit"
+                                    class="primary-button"
+                                    :title="trans('admin::app.settings.shipping_zone_rates.index.import.upload-btn')"
+                                    ::loading="importLoading"
+                                    ::disabled="importLoading"
+                                />
+                            </x-slot>
+                        </x-admin::modal>
+                    </form>
+                </x-admin::form>
+            </div>
+        </script>
+
+        <script type="module">
+            app.component('v-shipping-zone-rates', {
+                template: '#v-shipping-zone-rates-template',
+
+                data() {
+                    return {
+                        selectedRate: 0,
+                        selectedRateData: {},
+                        isLoading: false,
+                        importLoading: false,
+                    }
+                },
+
+                computed: {
+                    gridsCount() {
+                        let count = this.$refs.datagrid.available.columns.length;
+                        if (this.$refs.datagrid.available.actions.length) {
+                            ++count;
+                        }
+                        if (this.$refs.datagrid.available.massActions.length) {
+                            ++count;
+                        }
+                        return count;
+                    },
+                },
+
+                methods: {
+                    updateOrCreate(params, { resetForm, setErrors }) {
+                        this.isLoading = true;
+
+                        let formData = new FormData(this.$refs.rateCreateForm);
+
+                        if (params.id) {
+                            formData.append('_method', 'put');
+                        }
+
+                        this.$axios.post(
+                            params.id
+                                ? "{{ route('admin.settings.shipping_zone_rates.update') }}"
+                                : "{{ route('admin.settings.shipping_zone_rates.store') }}",
+                            formData
+                        )
+                            .then((response) => {
+                                this.isLoading = false;
+                                this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
+                                this.$refs.rateUpdateOrCreateModal.close();
+                                this.$refs.datagrid.get();
+                                resetForm();
+                            })
+                            .catch(error => {
+                                this.isLoading = false;
+                                if (error.response.status == 422) {
+                                    setErrors(error.response.data.errors);
+                                }
+                            });
+                    },
+
+                    editModal(url) {
+                        this.$axios.get(url)
+                            .then((response) => {
+                                this.selectedRateData = response.data.data;
+
+                                if (this.selectedRateData.is_active !== undefined) {
+                                    this.selectedRateData.is_active = this.selectedRateData.is_active ? '1' : '0';
+                                }
+
+                                this.$refs.rateUpdateOrCreateModal.toggle();
+                            })
+                            .catch(error => this.$emitter.emit('add-flash', {
+                                type: 'error', message: error.response.data.message
+                            }));
+                    },
+
+                    importCsv(params, { resetForm, setErrors }) {
+                        this.importLoading = true;
+
+                        let formData = new FormData(this.$refs.importForm);
+
+                        this.$axios.post(
+                            "{{ route('admin.settings.shipping_zone_rates.import') }}",
+                            formData
+                        )
+                            .then((response) => {
+                                this.importLoading = false;
+                                this.$emitter.emit('add-flash', { type: 'success', message: response.data.message });
+                                this.$refs.importModal.close();
+                                this.$refs.datagrid.get();
+                                resetForm();
+                            })
+                            .catch(error => {
+                                this.importLoading = false;
+                                if (error.response.status == 422) {
+                                    setErrors(error.response.data.errors);
+                                } else {
+                                    this.$emitter.emit('add-flash', {
+                                        type: 'error',
+                                        message: error.response.data.message || 'Import failed'
+                                    });
+                                }
+                            });
+                    },
+
+                    resetForm() {
+                        this.selectedRateData = {};
+                    }
+                }
+            })
+        </script>
+    @endPushOnce
+</x-admin::layouts>

+ 20 - 0
packages/Webkul/Admin/src/Routes/settings-routes.php

@@ -8,6 +8,7 @@ use Webkul\Admin\Http\Controllers\Settings\ExchangeRateController;
 use Webkul\Admin\Http\Controllers\Settings\InventorySourceController;
 use Webkul\Admin\Http\Controllers\Settings\LocaleController;
 use Webkul\Admin\Http\Controllers\Settings\RoleController;
+use Webkul\Admin\Http\Controllers\Settings\ShippingZoneRateController;
 use Webkul\Admin\Http\Controllers\Settings\Tax\TaxCategoryController;
 use Webkul\Admin\Http\Controllers\Settings\Tax\TaxRateController;
 use Webkul\Admin\Http\Controllers\Settings\ThemeController;
@@ -223,4 +224,23 @@ Route::prefix('settings')->group(function () {
             Route::get('download-error-report/{id}', 'downloadErrorReport')->name('admin.settings.data_transfer.imports.download_error_report');
         });
     });
+
+    /**
+     * Shipping Zone Rates routes.
+     */
+    Route::controller(ShippingZoneRateController::class)->prefix('shipping-zone-rates')->group(function () {
+        Route::get('', 'index')->name('admin.settings.shipping_zone_rates.index');
+
+        Route::post('create', 'store')->name('admin.settings.shipping_zone_rates.store');
+
+        Route::get('edit/{id}', 'edit')->name('admin.settings.shipping_zone_rates.edit');
+
+        Route::put('edit', 'update')->name('admin.settings.shipping_zone_rates.update');
+
+        Route::delete('edit/{id}', 'destroy')->name('admin.settings.shipping_zone_rates.delete');
+
+        Route::post('mass-delete', 'massDestroy')->name('admin.settings.shipping_zone_rates.mass_delete');
+
+        Route::post('import', 'import')->name('admin.settings.shipping_zone_rates.import');
+    });
 });

+ 2 - 0
packages/Webkul/Attribute/src/Models/AttributeFamily.php

@@ -9,7 +9,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
 use Webkul\Attribute\Contracts\AttributeFamily as AttributeFamilyContract;
 use Webkul\Attribute\Database\Factories\AttributeFamilyFactory;
 use Webkul\Product\Models\ProductProxy;
+use ApiPlatform\Metadata\ApiResource;
 
+#[ApiResource(operations: [], graphQlOperations: [])]
 class AttributeFamily extends Model implements AttributeFamilyContract
 {
     use HasFactory;

+ 93 - 1
packages/Webkul/BagistoApi/README.md

@@ -1,4 +1,4 @@
-# Bagisto API Platform
+、# Bagisto API Platform
 
 Comprehensive REST and GraphQL APIs for seamless e-commerce integration and extensibility.
 
@@ -84,6 +84,98 @@ Once verified, access the APIs at:
 - **GraphQL Endpoint**: https://your-domain.com/graphql`
 - **GraphQL Playground**: [https://your-domain.com/graphqli](https://api-demo.bagisto.com/api/graphiql?)
 
+## Payment Flow
+
+The payment pipeline supports two paths through a single set of GraphQL
+mutations:
+
+1. **Standard checkout** – buyer fills address/email/shipping method before
+   payment. Order is created with full tax/shipping totals.
+2. **Express checkout** – buyer skips address/email entirely. Order is
+   created with placeholder address and `tax_amount = shipping_amount = 0`;
+   the gateway response fills the buyer's real address on success.
+
+Both paths use the same state machine:
+
+```
++----------------+   paymentInitiate    +----------+   paymentCallback
+|  cart token    |  ─────────────────▶  | order(PENDING)|  ───────────▶ order(PROCESSING)
++----------------+                       +----------+                   (success)
+                                              │
+                                              │   paymentReplay
+                                              │   (re-issue gateway order id)
+                                              ▼
+                                          cancelOrder ── strategy ──▶ cancel / keep_pending
+```
+
+### Mutations
+
+| Mutation                  | Purpose                                                                |
+|---------------------------|------------------------------------------------------------------------|
+| `paymentInitiateCreate`   | Create the Bagisto order + a gateway order id in one shot. Returns the new cart token so the buyer can keep adding products to a fresh cart. Set `expressCheckout: true` to skip shipping/email validation. |
+| `paymentCallbackCreate`   | Frontend hits this after the gateway redirect. `status = success` triggers capture + (for express orders) writes the real shipping/billing address. `status = cancel | failure` leaves the order pending. |
+| `paymentReplayCreate`     | Authenticated. Generates a fresh gateway order id for a still-pending order so the buyer can retry payment. |
+| `cancelOrderCreate`       | Existing mutation; now strategy-aware: guest cancels immediately and reactivates the old cart; customer with shipping address stays pending; customer without shipping address follows the `bagistoapi.express_checkout.cancel_without_address` config (`cancel` by default, switchable to `keep_pending`). |
+
+### `payment.additional` keys
+
+`paymentInitiateCreate` stores the following keys onto the order's payment
+record so callbacks and the reconciliation job can pick the right branch:
+
+- `express`            – boolean; true for express-checkout orders.
+- `gateway_order_id`   – same value as `paypal_order_id` (kept for parity).
+- `paypal_order_id`    – PayPal order id when the gateway is Smart Button.
+- `cart_id`            – original cart id, used when reactivating a cart.
+- `cart_token`         – original guest cart token (guest carts only).
+
+### Dead-letter reconciliation
+
+Each successful `paymentInitiate` enqueues `ReconcilePendingPaymentJob`
+on the `bagistoapi.reconcile.queue` queue with a configurable delay
+(`bagistoapi.reconcile.delay_minutes`, default 15 min). The job:
+
+- exits cleanly if the order is no longer pending;
+- if the gateway reports a capture, dispatches
+  `bagistoapi.payment.reconcile.captured` so ops can investigate a missed
+  callback (likely a network failure on the client side);
+- otherwise calls `OrderRepository::cancel($order)` and dispatches
+  `bagistoapi.payment.reconcile.cancelled`.
+
+Route the queue to a dead-letter capable broker (e.g. RabbitMQ) via the
+standard Laravel queue config; the job itself is broker-agnostic.
+
+### Extension events
+
+The flow emits the following events that listeners can subscribe to:
+
+- `bagistoapi.payment.initiated`            – after order + gateway order created.
+- `bagistoapi.payment.success`              – after a successful capture.
+- `bagistoapi.payment.cancelled`            – cancel callback received.
+- `bagistoapi.payment.replayed`             – new gateway order issued for pending order.
+- `bagistoapi.express.cancel.no-address`    – customer cancels an express order that still has the placeholder address; config decides whether to actually cancel.
+- `bagistoapi.order.cancel.kept-pending`    – cancel mutation kept the order pending instead of cancelling.
+- `bagistoapi.payment.reconcile.captured`   – reconciliation found a capture on the gateway side but Bagisto is still pending.
+- `bagistoapi.payment.reconcile.cancelled`  – reconciliation cancelled the order.
+- `bagistoapi.payment.reconcile.voided`     – reconciliation found the gateway order in a terminal failed state.
+- `bagistoapi.payment.reconcile.no-gateway-info` – reconciliation found no gateway info on the order; surfaced for monitoring.
+
+### Configuration
+
+`config/bagistoapi.php` (publish via `php artisan vendor:publish --tag=bagistoapi-config`):
+
+```php
+'express_checkout' => [
+    'enabled'                => env('BAGISTOAPI_EXPRESS_CHECKOUT_ENABLED', true),
+    'cancel_without_address' => env('BAGISTOAPI_EXPRESS_CANCEL_WITHOUT_ADDRESS', 'cancel'),
+    'placeholder_address'    => [/* address used pre-payment */],
+],
+'reconcile' => [
+    'enabled'        => env('BAGISTOAPI_PAYMENT_RECONCILE_ENABLED', true),
+    'delay_minutes'  => (int) env('BAGISTOAPI_PAYMENT_RECONCILE_DELAY', 15),
+    'queue'          => env('BAGISTOAPI_PAYMENT_RECONCILE_QUEUE', 'payment-reconcile'),
+],
+```
+
 ## Documentation
 - Bagisto API: [Demo Page](https://api-demo.bagisto.com/api) 
 - API Documentation: [Bagisto API Docs](https://api-docs.bagisto.com/)

+ 10 - 2
packages/Webkul/BagistoApi/composer.json

@@ -11,7 +11,16 @@
   ],
   "require": {
     "api-platform/laravel": "v4.1.25",
-    "api-platform/graphql": "v4.2.3"
+    "api-platform/graphql": "v4.2.3",
+    "api-platform/metadata": "v4.3.1",
+    "api-platform/serializer": "v4.3.1",
+    "api-platform/state": "v4.3.1",
+    "api-platform/jsonld": "v4.3.1",
+    "api-platform/hydra": "v4.3.1",
+    "api-platform/openapi": "v4.3.1",
+    "api-platform/json-schema": "v4.3.1",
+    "api-platform/json-api": "v4.3.1",
+    "api-platform/documentation": "v4.3.1"
   },
   "autoload": {
     "psr-4": {
@@ -31,4 +40,3 @@
     ]
   }
 }
-

+ 6 - 3
packages/Webkul/BagistoApi/config/api-platform-vendor.php

@@ -43,12 +43,15 @@ return [
             'Webkul\BagistoApi\Http\Middleware\VerifyStorefrontKey',
             'Webkul\BagistoApi\Http\Middleware\BagistoApiDocumentationMiddleware',
             'Webkul\BagistoApi\Http\Middleware\ForceApiJson',
+            'Webkul\BagistoApi\Http\Middleware\PaginationHeaders',
             'Spatie\ResponseCache\Middlewares\CacheResponse',
         ],
     ],
 
     'resources' => [
-        base_path('vendor/bagisto/bagisto-api/src/Models/')
+        base_path('vendor/bagisto/bagisto-api/src/Models/'),
+        base_path('vendor/bagisto/bagisto-api/src/Dto/ProductDetail/'),
+        base_path('vendor/bagisto/bagisto-api/src/Dto/CustomerOrder/'),
     ],
 
     'formats' => [
@@ -72,7 +75,7 @@ return [
         'pagination_enabled'                => true,
         'pagination_partial'                => false,
         'pagination_client_enabled'         => false,
-        'pagination_client_items_per_page'  => false,
+        'pagination_client_items_per_page'  => true,
         'pagination_client_partial'         => false,
         'pagination_items_per_page'         => 10,
         'pagination_maximum_items_per_page' => 50,
@@ -83,7 +86,7 @@ return [
     'pagination' => [
         'page_parameter_name'           => 'page',
         'enabled_parameter_name'        => 'pagination',
-        'items_per_page_parameter_name' => 'itemsPerPage',
+        'items_per_page_parameter_name' => 'per_page',
         'partial_parameter_name'        => 'partial',
     ],
 

+ 75 - 0
packages/Webkul/BagistoApi/config/bagistoapi.php

@@ -0,0 +1,75 @@
+<?php
+
+/*
+|--------------------------------------------------------------------------
+| BagistoApi Configuration
+|--------------------------------------------------------------------------
+|
+| Switches for the BagistoApi payment / express-checkout flow.
+|
+*/
+
+return [
+
+    /*
+    |--------------------------------------------------------------------------
+    | Express Checkout
+    |--------------------------------------------------------------------------
+    |
+    | express_checkout.enabled
+    |   Master switch for the quick (no email/address) payment path.
+    |
+    | express_checkout.cancel_without_address
+    |   Strategy for "registered customer cancels express payment AND order
+    |   has no shipping address" scenario:
+    |     - "cancel"       (default) cancel the order immediately.
+    |     - "keep_pending" leave order pending; caller can call paymentReplay
+    |                      later or operations team can intervene via the
+    |                      `bagistoapi.express.cancel.no-address` event.
+    |
+    | express_checkout.placeholder_address
+    |   Placeholder address used for the pre-payment order when no real
+    |   shipping address is known yet. Re-written from the gateway response
+    |   on a successful capture callback.
+    */
+    'express_checkout' => [
+        'enabled'                   => env('BAGISTOAPI_EXPRESS_CHECKOUT_ENABLED', true),
+        'cancel_without_address'    => env('BAGISTOAPI_EXPRESS_CANCEL_WITHOUT_ADDRESS', 'cancel'),
+        'placeholder_address'       => [
+            'first_name'   => 'Express',
+            'last_name'    => 'Checkout',
+            'email'        => 'express-checkout@placeholder.local',
+            'address'      => 'Pending gateway response',
+            'city'         => 'Pending',
+            'state'        => 'Pending',
+            'country'      => 'US',
+            'postcode'     => '00000',
+            'phone'        => '0000000000',
+        ],
+    ],
+
+    /*
+    |--------------------------------------------------------------------------
+    | Payment Reconciliation Job
+    |--------------------------------------------------------------------------
+    |
+    | reconcile.enabled
+    |   Toggle the delayed reconciliation job that polls the gateway after
+    |   `delay_minutes` if the order is still PENDING.
+    |
+    | reconcile.delay_minutes
+    |   How long to wait before a pending order is reconciled against the
+    |   gateway.
+    |
+    | reconcile.queue
+    |   Name of the queue the reconciliation job is dispatched onto. Operators
+    |   can route this to a dead-letter capable broker (e.g. RabbitMQ) via the
+    |   Laravel queue config without touching code.
+    */
+    'reconcile' => [
+        'enabled'        => env('BAGISTOAPI_PAYMENT_RECONCILE_ENABLED', true),
+        'delay_minutes'  => (int) env('BAGISTOAPI_PAYMENT_RECONCILE_DELAY', 15),
+        'queue'          => env('BAGISTOAPI_PAYMENT_RECONCILE_QUEUE', 'payment-reconcile'),
+    ],
+
+];

+ 6 - 1
packages/Webkul/BagistoApi/src/Console/Commands/InstallApiPlatformCommand.php

@@ -402,10 +402,15 @@ class InstallApiPlatformCommand extends Command
                 'optimize',
             ]);
 
+            $optimizeProcess->setTimeout(300);
+            
             $optimizeProcess->run();
 
             if (! $optimizeProcess->isSuccessful()) {
-                throw new \Exception(__('bagistoapi::app.graphql.install.cache-optimize-error').' '.$optimizeProcess->getErrorOutput());
+                  // Cache optimization is a performance step, not required for a working
+                // install. Warn and continue instead of failing the whole command.
+                // (The real error is usually on stdout; getErrorOutput() is often empty.)
+                $this->warn(__('bagistoapi::app.graphql.install.cache-optimize-error').' '.trim($optimizeProcess->getErrorOutput().' '.$optimizeProcess->getOutput()));   
             }
 
             $this->line(__('bagistoapi::app.graphql.install.caches-optimized'));

+ 38 - 0
packages/Webkul/BagistoApi/src/Console/Commands/OptimizeApiPlatformCommand.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace Webkul\BagistoApi\Console\Commands;
+
+use Illuminate\Console\Command;
+
+class OptimizeApiPlatformCommand extends Command
+{
+    protected $signature = 'bagisto-api-platform:optimize';
+
+    protected $description = 'Full deploy optimization for the API: clears stale caches, then rebuilds the config + route caches and pre-warms the API Platform metadata cache. Run this after every deploy, package update, or endpoint change so no request pays the per-request route rebuild or the cold-start metadata build.';
+
+    public function handle(): int
+    {
+        $this->components->info('Optimizing the Bagisto API (full optimize + metadata caches)...');
+
+        $this->call('optimize:clear');
+        $this->call('bagisto-api-platform:clear-cache');
+        $this->call('optimize');
+
+        if (! app()->routesAreCached()) {
+            $this->components->error('The route cache was not built. Without it, API Platform re-registers every route on every request (~0.8s slower per call). Check for a route that cannot be serialized (a closure or a duplicate route name), then re-run this command before deploying.');
+
+            return self::FAILURE;
+        }
+
+        $this->call('bagisto-api-platform:warm-cache');
+
+        $this->components->info('Bagisto API optimized. Config, events, routes and views are cached and the metadata cache is warm.');
+
+        if (config('app.debug')) {
+            $this->newLine();
+            $this->components->warn('APP_DEBUG is currently true. For faster responses set APP_DEBUG=false in your .env (debug mode adds error-collector overhead to every request), then re-run this command.');
+        }
+
+        return self::SUCCESS;
+    }
+}

+ 35 - 0
packages/Webkul/BagistoApi/src/Console/Commands/PruneAuditsCommand.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace Webkul\BagistoApi\Console\Commands;
+
+use Illuminate\Console\Command;
+use Webkul\BagistoApi\Admin\Models\AdminApiAudit;
+
+/**
+ * Deletes admin-API audit history older than the configured retention period.
+ * Schedule it (or run manually) to keep the audit table from growing forever.
+ */
+class PruneAuditsCommand extends Command
+{
+    protected $signature = 'bagisto-api:prune-audits {--days= : Override bagistoapi.audit.retention_days}';
+
+    protected $description = 'Delete admin-API audit history older than the retention period.';
+
+    public function handle(): int
+    {
+        $days = $this->option('days') ?? config('bagistoapi.audit.retention_days');
+
+        if ($days === null || ! is_numeric($days)) {
+            $this->info('No retention period configured (bagistoapi.audit.retention_days is null). Nothing pruned.');
+
+            return self::SUCCESS;
+        }
+
+        $cutoff = now()->subDays((int) $days);
+        $deleted = AdminApiAudit::where('created_at', '<', $cutoff)->delete();
+
+        $this->info("Pruned {$deleted} audit row(s) older than {$days} day(s).");
+
+        return self::SUCCESS;
+    }
+}

+ 51 - 0
packages/Webkul/BagistoApi/src/Console/Commands/WarmApiPlatformCacheCommand.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Webkul\BagistoApi\Console\Commands;
+
+use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
+use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface;
+use Illuminate\Console\Command;
+
+class WarmApiPlatformCacheCommand extends Command
+{
+    protected $signature = 'bagisto-api-platform:warm-cache';
+
+    protected $description = 'Pre-build the API Platform resource-metadata cache so the first request after a deploy/cache-clear does not pay the full metadata rebuild cost.';
+
+    public function handle(): int
+    {
+        $nameFactory = app(ResourceNameCollectionFactoryInterface::class);
+        $metadataFactory = app(ResourceMetadataCollectionFactoryInterface::class);
+
+        $count = 0;
+        $failed = 0;
+
+        foreach ($nameFactory->create() as $resourceClass) {
+            try {
+                $metadataFactory->create($resourceClass);
+                $count++;
+            } catch (\Throwable $e) {
+                $failed++;
+                $this->warn(sprintf('Skipped "%s": %s', $resourceClass, $e->getMessage()));
+            }
+        }
+
+        $this->info(sprintf('Warmed metadata cache for %d resource(s)%s.', $count, $failed ? sprintf(' (%d skipped)', $failed) : ''));
+
+        try {
+            $scopedBuilder = new \Webkul\BagistoApi\GraphQl\QueryScopedSchemaBuilder(
+                $nameFactory,
+                $metadataFactory,
+                app(\ApiPlatform\GraphQl\Type\TypesFactoryInterface::class),
+                app(\ApiPlatform\GraphQl\Type\TypesContainerInterface::class),
+                app(\ApiPlatform\GraphQl\Type\FieldsBuilderEnumInterface::class),
+            );
+
+            $this->info(sprintf('Pre-built the GraphQL query-scope map (%d fields).', $scopedBuilder->warmFieldMap()));
+        } catch (\Throwable $e) {
+            $this->warn(sprintf('Could not pre-build the GraphQL query-scope map: %s', $e->getMessage()));
+        }
+
+        return self::SUCCESS;
+    }
+}

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

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

+ 35 - 0
packages/Webkul/BagistoApi/src/Database/Migrations/2026_06_02_000000_create_payment_attempts_table.php

@@ -0,0 +1,35 @@
+<?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('payment_attempts', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedInteger('order_id')->nullable()->index();
+            $table->unsignedInteger('cart_id')->nullable()->index();
+            $table->string('payment_method')->nullable();
+            $table->string('gateway_order_id')->nullable()->index();
+            $table->string('action')->index();
+            $table->string('status')->index();
+            $table->decimal('amount', 12, 4)->default(0)->nullable();
+            $table->string('currency')->nullable();
+            $table->boolean('express')->default(false);
+            $table->json('request_payload')->nullable();
+            $table->json('response_payload')->nullable();
+            $table->string('idempotency_key')->nullable()->unique();
+            $table->timestamps();
+
+            $table->foreign('order_id')->references('id')->on('orders')->onDelete('cascade');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('payment_attempts');
+    }
+};

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


+ 3 - 3
packages/Webkul/BagistoApi/src/Dto/ContactUsInput.php

@@ -15,14 +15,14 @@ class ContactUsInput
     public ?string $id = null;
 
     #[Groups(['mutation'])]
-    public string $name;
+    public ?string $name = null;
 
     #[Groups(['mutation'])]
-    public string $email;
+    public ?string $email = null;
 
     #[Groups(['mutation'])]
     public ?string $contact = null;
 
     #[Groups(['mutation'])]
-    public string $message;
+    public ?string $message = null;
 }

+ 17 - 0
packages/Webkul/BagistoApi/src/Dto/CreateGdprRequestInput.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+
+class CreateGdprRequestInput
+{
+    #[ApiProperty(description: 'The request type. Either "delete" or "update".')]
+    #[Groups(['mutation'])]
+    public ?string $type = null;
+
+    #[ApiProperty(description: 'The reason or message for the request.')]
+    #[Groups(['mutation'])]
+    public ?string $message = null;
+}

+ 169 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerOrder/CustomerOrderDetailDto.php

@@ -0,0 +1,169 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\CustomerOrder;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+
+/**
+ * Detail-view shape for /customer-orders/{id}.
+ *
+ * Mirrors all order-level fields the legacy response exposed, but replaces the
+ * dangling IRI relations (items, addresses, payment, shipments) with embedded
+ * objects so a single request returns everything the detail UI needs.
+ */
+#[ApiResource(
+    operations: [],
+    graphQlOperations: [],
+    normalizationContext: ['skip_null_values' => false],
+)]
+class CustomerOrderDetailDto
+{
+    #[ApiProperty(identifier: true)]
+    public ?int $id = null;
+
+    public ?string $increment_id = null;
+
+    public ?string $status = null;
+
+    public ?string $channel_name = null;
+
+    public ?bool $is_guest = null;
+
+    public ?string $customer_email = null;
+
+    public ?string $customer_first_name = null;
+
+    public ?string $customer_last_name = null;
+
+    public ?string $customer_full_name = null;
+
+    public ?string $shipping_method = null;
+
+    public ?string $shipping_title = null;
+
+    public ?string $shipping_description = null;
+
+    public ?string $coupon_code = null;
+
+    public ?bool $is_gift = null;
+
+    public ?int $total_item_count = null;
+
+    public ?int $total_qty_ordered = null;
+
+    public ?string $base_currency_code = null;
+
+    public ?string $channel_currency_code = null;
+
+    public ?string $order_currency_code = null;
+
+    public ?float $grand_total = null;
+
+    public ?float $base_grand_total = null;
+
+    public ?float $grand_total_invoiced = null;
+
+    public ?float $base_grand_total_invoiced = null;
+
+    public ?float $grand_total_refunded = null;
+
+    public ?float $base_grand_total_refunded = null;
+
+    public ?float $sub_total = null;
+
+    public ?float $base_sub_total = null;
+
+    public ?float $sub_total_invoiced = null;
+
+    public ?float $base_sub_total_invoiced = null;
+
+    public ?float $sub_total_refunded = null;
+
+    public ?float $base_sub_total_refunded = null;
+
+    public ?float $discount_percent = null;
+
+    public ?float $discount_amount = null;
+
+    public ?float $base_discount_amount = null;
+
+    public ?float $discount_invoiced = null;
+
+    public ?float $base_discount_invoiced = null;
+
+    public ?float $discount_refunded = null;
+
+    public ?float $base_discount_refunded = null;
+
+    public ?float $tax_amount = null;
+
+    public ?float $base_tax_amount = null;
+
+    public ?float $tax_amount_invoiced = null;
+
+    public ?float $base_tax_amount_invoiced = null;
+
+    public ?float $tax_amount_refunded = null;
+
+    public ?float $base_tax_amount_refunded = null;
+
+    public ?float $shipping_amount = null;
+
+    public ?float $base_shipping_amount = null;
+
+    public ?float $shipping_invoiced = null;
+
+    public ?float $base_shipping_invoiced = null;
+
+    public ?float $shipping_refunded = null;
+
+    public ?float $base_shipping_refunded = null;
+
+    public ?float $shipping_discount_amount = null;
+
+    public ?float $base_shipping_discount_amount = null;
+
+    public ?float $shipping_tax_amount = null;
+
+    public ?float $base_shipping_tax_amount = null;
+
+    public ?float $shipping_tax_refunded = null;
+
+    public ?float $base_shipping_tax_refunded = null;
+
+    public ?float $sub_total_incl_tax = null;
+
+    public ?float $base_sub_total_incl_tax = null;
+
+    public ?float $shipping_amount_incl_tax = null;
+
+    public ?float $base_shipping_amount_incl_tax = null;
+
+    public ?int $customer_id = null;
+
+    public ?int $channel_id = null;
+
+    public ?int $cart_id = null;
+
+    public ?string $applied_cart_rule_ids = null;
+
+    public ?string $created_at = null;
+
+    public ?string $updated_at = null;
+
+    /** @var OrderItemDto[] */
+    #[ApiProperty(readableLink: true)]
+    public array $items = [];
+
+    /** @var OrderAddressDto[] */
+    #[ApiProperty(readableLink: true)]
+    public array $addresses = [];
+
+    #[ApiProperty(readableLink: true)]
+    public ?OrderPaymentDto $payment = null;
+
+    /** @var OrderShipmentDto[] */
+    #[ApiProperty(readableLink: true)]
+    public array $shipments = [];
+}

+ 72 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerOrder/CustomerOrderListDto.php

@@ -0,0 +1,72 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\CustomerOrder;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+
+/**
+ * List-view shape for /customer-orders.
+ *
+ * Mirrors the GraphQL `customerOrders` query field set so REST and GraphQL
+ * lists return the same payload. Heavy or admin-only fields (-invoiced,
+ * -refunded, polymorphic IDs, cart references, dangling relation IRIs) live
+ * only on the detail endpoint.
+ *
+ * Relations (items, addresses, payment, shipments) intentionally omitted —
+ * the detail endpoint serves those.
+ */
+#[ApiResource(
+    operations: [],
+    graphQlOperations: [],
+    normalizationContext: ['skip_null_values' => false],
+)]
+class CustomerOrderListDto
+{
+    #[ApiProperty(identifier: true)]
+    public ?int $id = null;
+
+    public ?string $increment_id = null;
+
+    public ?string $status = null;
+
+    public ?string $channel_name = null;
+
+    public ?string $customer_email = null;
+
+    public ?string $customer_first_name = null;
+
+    public ?string $customer_last_name = null;
+
+    public ?string $shipping_method = null;
+
+    public ?string $shipping_title = null;
+
+    public ?string $coupon_code = null;
+
+    public ?int $total_item_count = null;
+
+    public ?int $total_qty_ordered = null;
+
+    public ?float $grand_total = null;
+
+    public ?float $base_grand_total = null;
+
+    public ?float $sub_total = null;
+
+    public ?float $base_sub_total = null;
+
+    public ?float $tax_amount = null;
+
+    public ?float $shipping_amount = null;
+
+    public ?float $discount_amount = null;
+
+    public ?string $base_currency_code = null;
+
+    public ?string $order_currency_code = null;
+
+    public ?string $created_at = null;
+
+    public ?string $updated_at = null;
+}

+ 37 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerOrder/OrderAddressDto.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\CustomerOrder;
+
+use ApiPlatform\Metadata\ApiResource;
+
+#[ApiResource(operations: [], graphQlOperations: [], normalizationContext: ['skip_null_values' => false])]
+class OrderAddressDto
+{
+    public ?int $id = null;
+
+    public ?string $address_type = null;
+
+    public ?string $first_name = null;
+
+    public ?string $last_name = null;
+
+    public ?string $gender = null;
+
+    public ?string $company_name = null;
+
+    public ?string $address = null;
+
+    public ?string $city = null;
+
+    public ?string $state = null;
+
+    public ?string $country = null;
+
+    public ?string $postcode = null;
+
+    public ?string $email = null;
+
+    public ?string $phone = null;
+
+    public ?string $vat_id = null;
+}

+ 51 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerOrder/OrderItemDto.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\CustomerOrder;
+
+use ApiPlatform\Metadata\ApiResource;
+
+#[ApiResource(operations: [], graphQlOperations: [], normalizationContext: ['skip_null_values' => false])]
+class OrderItemDto
+{
+    public ?int $id = null;
+
+    public ?string $sku = null;
+
+    public ?string $type = null;
+
+    public ?string $name = null;
+
+    public ?int $product_id = null;
+
+    public ?string $product_type = null;
+
+    public ?int $qty_ordered = null;
+
+    public ?int $qty_shipped = null;
+
+    public ?int $qty_invoiced = null;
+
+    public ?int $qty_canceled = null;
+
+    public ?int $qty_refunded = null;
+
+    public ?float $price = null;
+
+    public ?float $base_price = null;
+
+    public ?float $total = null;
+
+    public ?float $base_total = null;
+
+    public ?float $discount_percent = null;
+
+    public ?float $discount_amount = null;
+
+    public ?float $tax_percent = null;
+
+    public ?float $tax_amount = null;
+
+    public ?float $price_incl_tax = null;
+
+    public ?float $total_incl_tax = null;
+}

+ 15 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerOrder/OrderPaymentDto.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\CustomerOrder;
+
+use ApiPlatform\Metadata\ApiResource;
+
+#[ApiResource(operations: [], graphQlOperations: [], normalizationContext: ['skip_null_values' => false])]
+class OrderPaymentDto
+{
+    public ?int $id = null;
+
+    public ?string $method = null;
+
+    public ?string $method_title = null;
+}

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

@@ -0,0 +1,34 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\CustomerOrder;
+
+use ApiPlatform\Metadata\ApiProperty;
+use ApiPlatform\Metadata\ApiResource;
+
+#[ApiResource(operations: [], graphQlOperations: [], normalizationContext: ['skip_null_values' => false])]
+class OrderShipmentDto
+{
+    public ?int $id = null;
+
+    public ?string $shipping_number = null;
+
+    public ?string $carrier_title = null;
+
+    public ?string $carrier_code = null;
+
+    public ?string $track_number = null;
+
+    public ?int $total_qty = null;
+
+    public ?float $total_weight = null;
+
+    public ?bool $email_sent = null;
+
+    public ?string $created_at = null;
+
+    public ?string $updated_at = null;
+
+    /** @var OrderShipmentItemDto[] */
+    #[ApiProperty(readableLink: true)]
+    public array $items = [];
+}

+ 17 - 0
packages/Webkul/BagistoApi/src/Dto/CustomerOrder/OrderShipmentItemDto.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto\CustomerOrder;
+
+use ApiPlatform\Metadata\ApiResource;
+
+#[ApiResource(operations: [], graphQlOperations: [], normalizationContext: ['skip_null_values' => false])]
+class OrderShipmentItemDto
+{
+    public ?int $id = null;
+
+    public ?int $order_item_id = null;
+
+    public ?int $qty = null;
+
+    public ?float $weight = null;
+}

+ 8 - 1
packages/Webkul/BagistoApi/src/Dto/CustomerProfileInput.php

@@ -57,8 +57,15 @@ class CustomerProfileInput
     #[Groups(['mutation'])]
     public ?string $dateOfBirth = null;
 
+
+
+    /**
+     * Current password — required when changing password
+     */
+    #[Groups(['mutation'])]
+    public ?string $currentPassword = null;
     /**
-     * Current password (for password change verification)
+     * New password
      */
     #[Groups(['mutation'])]
     public ?string $password = null;

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

@@ -0,0 +1,11 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+
+class DeleteGdprRequestInput
+{
+    #[ApiProperty(description: 'The ID of the GDPR data request to delete')]
+    public ?string $id = null;
+}

+ 62 - 0
packages/Webkul/BagistoApi/src/Dto/PaymentCallbackInput.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace Webkul\BagistoApi\Dto;
+
+use ApiPlatform\Metadata\ApiProperty;
+use Symfony\Component\Serializer\Annotation\Groups;
+use Symfony\Component\Serializer\Annotation\SerializedName;
+
+/**
+ * GraphQL input for paymentCallbackCreate.
+ *
+ * Frontends call this after the gateway redirects back, indicating
+ * `status = success | cancel | failure`. For express success calls
+ * the gateway shipping/billing fields populated from the PayPal
+ * response should be forwarded so the order address can be filled.
+ */
+class PaymentCallbackInput
+{
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Bagisto order id created by paymentInitiate')]
+    #[SerializedName('orderId')]
+    public ?int $orderId = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Gateway-side order id / token (e.g. PayPal order id)')]
+    #[SerializedName('gatewayOrderId')]
+    public ?string $gatewayOrderId = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'success | cancel | failure')]
+    #[SerializedName('status')]
+    public ?string $status = null;
+
+    #[Groups(['mutation'])]
+    #[ApiProperty(description: 'Optional signature/proof forwarded from gateway')]
+    #[SerializedName('paymentSignature')]
+    public ?string $paymentSignature = null;
+
+    /*
+    | Optional address payload (express flow only)
+    */
+
+    #[Groups(['mutation'])] #[SerializedName('shippingFirstName')] public ?string $shippingFirstName = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingLastName')]  public ?string $shippingLastName = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingEmail')]     public ?string $shippingEmail = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingAddress')]   public ?string $shippingAddress = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingCountry')]   public ?string $shippingCountry = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingState')]     public ?string $shippingState = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingCity')]      public ?string $shippingCity = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingPostcode')]  public ?string $shippingPostcode = null;
+    #[Groups(['mutation'])] #[SerializedName('shippingPhoneNumber')] public ?string $shippingPhoneNumber = null;
+
+    #[Groups(['mutation'])] #[SerializedName('billingFirstName')] public ?string $billingFirstName = null;
+    #[Groups(['mutation'])] #[SerializedName('billingLastName')]  public ?string $billingLastName = null;
+    #[Groups(['mutation'])] #[SerializedName('billingEmail')]     public ?string $billingEmail = null;
+    #[Groups(['mutation'])] #[SerializedName('billingAddress')]   public ?string $billingAddress = null;
+    #[Groups(['mutation'])] #[SerializedName('billingCountry')]   public ?string $billingCountry = null;
+    #[Groups(['mutation'])] #[SerializedName('billingState')]     public ?string $billingState = null;
+    #[Groups(['mutation'])] #[SerializedName('billingCity')]      public ?string $billingCity = null;
+    #[Groups(['mutation'])] #[SerializedName('billingPostcode')]  public ?string $billingPostcode = null;
+    #[Groups(['mutation'])] #[SerializedName('billingPhoneNumber')] public ?string $billingPhoneNumber = null;
+}

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


Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov